Uh oh!
There was an error while loading. Please reload this page.
feat(client): wire meta.saveItem's ifMatch option to the If-Match OCC header, on both declarations - #12189
Conversation
`saveItem`'s docstring named the ADR-0008 OCC protocol — echo the resolved `version` back as `If-Match` — while neither declaration accepted a header or anything that became one. Both REST PUT doors read `if-match` and thread it as `parentVersion`, so the instruction was true of a raw-HTTP caller and false of a first-party SDK caller, whose concurrent edit overwrote and was answered 200. `ifMatch?: string` joins the `SaveMetaItemOptions` bag on BOTH declarations, through one shared `metaSaveHeaders` builder beside the existing shared `metaSaveQuery` — the twins cannot drift. Only a non-empty token reaches the wire; unset, the `init` carries no `headers` key at all, so every existing call is byte-identical and last-write-wins stays the default. Pins assert the built HEADERS in both directions (present with the token, absent without) on both clients — the pre-existing #11391 pins measure the URL only, and a URL pin cannot see a value that goes nowhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin eab2c2f998898f0cfdb698c969eea2c93fb8e4c2 && git checkout eab2c2f998898f0cfdb698c969eea2c93fb8e4c2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2c4c59ed20caf4f6cde3c979ae9205ee69e04256 cff19f835b0d211132177152b3d167ac5cbd087b && git checkout -B drift-repro 2c4c59ed20caf4f6cde3c979ae9205ee69e04256 && git merge --no-ff cff19f835b0d211132177152b3d167ac5cbd087b
node scripts/docs-audit/affected-docs.mjs --json 2c4c59ed20caf4f6cde3c979ae9205ee69e04256
|
os-trump
commented
Aug 25, 2026
Local gate results — 22 green, 1 refusal (declared, not counted)Run 2 under the shared verify lock:
Two ratchets confirmed their baselines against the merge base rather than merely passing: The one non-zero is a refusal — ⛔ not a failure, and ⛔ not a pass
Its Generated by Claude Code |
os-trump
commented
Aug 25, 2026
|
| step | result |
|---|---|
pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' | FULLBUILD_EXIT=0 — 202 build successes, Time: 9m3.747s |
pnpm check:type-check-debt | TYPE_CHECK_DEBT_EXIT=0 |
Its own verdict lines, quoted rather than paraphrased:
check-type-check-coverage --re-measure: OK — 32 ledger entr(ies) re-measured in 399.3s, 1843 raw tsc error(s) total, none above its recorded number.surplus: none — every entry sits exactly at its measurement, so any new error is red.
So the one gate that could not answer earlier now answers, on the same commit 7d312be1, with tsc resolving every workspace import through built dist/*.d.ts — the world the ledger's numbers were recorded in. "surplus: none" is the sharp half: every entry sits exactly at its recorded number, so this change added no tsc error anywhere in the ledger, and there was no headroom for it to hide in.
Local tally is now 23 of 23 measured, 23 green — nothing left declared unmeasured on this PR. CI still owns the authoritative run of the whole farm.
Generated by Claude Code
os-trump
commented
Aug 25, 2026
Contract-review verdict: PASS (#11713)Reviewed by the contract-review chain at Reviewed: the full diff, the PR body's evidence, and the ruling record on #11713. Conformance to ruling A (maintainer 2026-08-25, 「同意」 — option A, quoted in the PR body):
Clearing Generated by Claude Code |
os-trump
commented
Aug 25, 2026
Landing completed by the contract-review chain — ready + auto-merge armed (merge queue lands it when CI is green). This supersedes the "landing stays with the owning seat" line in the PASS verdict above, on maintainer instruction (2026-08-25, live PM chat, verbatim and untranslated): 「审核通过你应该直接负责合并吧,还要等原始的项目经理吗」. Standing practice from here: a contract-review PASS on a non-governed code PR completes the landing in the same stroke. Generated by Claude Code |
Fixes#11713
Maintainer ruling on the card (2026-08-25, verbatim 「同意」, accepting option A):
ifMatchbecomes an optional member of theSaveMetaItemOptionsbag #11391 landed, wired to theIf-Matchrequest header, on bothsaveItemdeclarations.The defect
meta.saveItem's own docstring has always named the ADR-0008 optimistic-concurrency protocol:Both REST
PUTdoors readif-matchand thread it asparentVersion, so that sentence was true of a raw-HTTP caller. It was false of a first-party SDK caller: neithersaveItemdeclaration accepted a header, anifMatch, or anything that became one. A caller who did exactly what the docstring said had nowhere to put the token, and their concurrent edit overwrote the other author's write — answered200, with no signal at the call site. Declared, not enforced.The change
ifMatch?: stringjoinsSaveMetaItemOptions— the same bag, extended rather than a second parameter, so the two clients keep one type between them.ObjectStackClient.meta.saveItemand the environment-scopedScopedProjectClient.meta.saveItem, through one sharedmetaSaveHeadersbuilder placed beside the existing sharedmetaSaveQuery. The twins cannot drift in what they put on the wire; that is the same reason the query builder is one function.undefinedand''both omit the header, and the builder returnsundefinedrather than{}so the call site omits theheaderskey from theinitentirely. An un-pinned save therefore puts exactly the header set on the wire it always did, and last-write-wins stays the default on both the client and the door.saveItem,publishItem,packages.publishDrafts). Option C — docs going on teaching a move the SDK cannot make — is closed, as the ruling requires; the B fallback (deleting the pointers) was not needed.Measurements the ruling asked for
1. The premise, re-verified on the merged ref.
packages/client/src/index.tsat22c42c9bdoes containif (opts?.ifMatch) headers['If-Match'] = String(opts.ifMatch);— twice, ondata.update(:5007) anddata.delete(:5105), not onmeta.saveItem. The twosaveItemdeclarations measured at:854(unscoped) and:5508(scoped) offered no way to send the header. Premise intact.2. The existing #11391 pins measure the URL only — confirmed, and no headers pin on
saveItemexisted. Every assertion in the two[#11391]describe blocks readsfetchMock.mock.calls[i][0](the URL) plusinit.method/init.body. That is why a URL pin cannot see this defect: a swallowedifMatchleaves the URL byte-identical, which is exactly what those pins assert. The only header-reading helper in the file belonged to the locale suite.3. Alignment with
@object-ui/data-objectstack'sMetadataClient— measured, not assumed (objectui at090927f,packages/data-objectstack/src/metadata-client.ts). The two first-party clients agree on every behavioural dimension:MetadataClient.savemeta.saveItem(this PR)ifMatch?: stringon the save-options bagifMatch?: stringonSaveMetaItemOptionsIf-MatchIf-Matchif (options.ifMatch)— falsy omitsif (!options?.ifMatch) return undefined— falsy omitsOne spelling difference, reported rather than reconciled: this client wraps the value in
String(...), matching its owndata.update/data.deletesiblings in the same file;MetadataClientassigns it directly. For every value the declaredstringtype admits the two are byte-identical, andfetchstringifies header values regardless — so the behaviour is one behaviour, and the coercion only differs for untyped JS callers passing a non-string. In-file consistency was preferred there, since divergence within one file is the defect class this card is about.Wording note, also reported rather than reconciled:
MetadataClientnames the token "thechecksumreturned by the last read"; this SDK names it "the resolvedversion". Both are the row's content hash —SaveMetaItemResponseSchema.versionis declared as "Content hash of the just-committed body, and the token the ADR-0008 optimistic-concurrency chain runs on", and the repository comparesparentVersionagainstcurrentHead— so the two names describe one token reached from each client's own surface.4.
ifMatchreaches BOTH doors — unlike the bag'smode. The compound-name twinPUT /meta/:type/:section/:name(rest-server.ts:6805) readsif-matchand strips ETag-style quotes at:6868, exactly as the single-segment door does at:5633. SosaveItem('object', 'views/all_leads', item, { ifMatch })is OCC-guarded like any other save. This is called out in the member's doc comment besidemode's COMPOUND NAMES DO NOT STAGE warning, so the next reader does not generalise the wrong way, and it is pinned.Pins — on the HEADERS the method builds, in both directions
packages/client/src/client.test.ts, three new[#11713]describe blocks, 12 cases. Each asserts the header present with the caller's token when supplied and absent when it is not — a pin asserting only that the option is accepted, or that the URL is unchanged, cannot see a value that goes nowhere.Covered on both clients: the token on the wire verbatim and unquoted · absent when unpinned, asserted as the exact header set on the wire rather than merely "no
If-Match" (byte-identity; the client's privatefetchalways merges a header object before callingfetchImpl, so an absence claim has to be spelled on the names it emits) · absent for a bag with no token · absent for''· the URL byte-identical to an unpinned save, proving this is a header and not a smuggled query parameter · riding alongside the three #11391 query parameters without disturbing them · a compound name · the twins in step, pinned and unpinned. Plus an end-to-end case: save, pin the resolvedversion, and the stale write is refused with the real envelope (code: 'METADATA_CONFLICT',httpStatus: 409— the envelope pinned inpackages/rest/src/rest.test.ts), asserted on the envelope the caller branches on rather than a bare.toThrow().Reverse verification — the pins go red without the fix
The narrow ablation, not a whole-file revert: the
ifMatchmember stays declared,metaSaveHeadersstays and still runs — only the two...(headers ? { headers } : {}),spreads that put the built header on the request were removed. That reproduces the defect exactly as it stood: the option is accepted, and the value goes nowhere.Mutation confirmed on disk in both directions before the run (an editor's exit code is not evidence — a zero-hit
sedexits 0):Result — direction red, as predicted:
All 7 failures are
[#11713]cases asserting the header is present, on both clients, including the end-to-end conflict loop. No rebuild was involved and none was needed:client.test.tsimports./index— the source module, not the packageexports→dist/path — so the mutation is on the code the run executes. The script restores the tree from atrap … EXIT INT TERM, and the restore was verified after it fired (0 modified path(s), spread count back to2).Two things this measures that a URL pin cannot. Under the same ablation, all fourteen pre-existing
[#11391]URL pins stayed green — which is the point: a swallowedifMatchleaves the URL byte-identical, so the existing pins are structurally blind to this defect. And the five[#11713]cases asserting the header absent also stayed green, correctly: they are regression guards for the un-pinned path, ⛔ not red-before evidence, and are named as such rather than counted.Verification
Every heavy step ran through
scripts/pm/os-verify-lock.sh; verdicts are read from the lock's ownVERDICTline, never a bare$?. Run on7d312be1, the final commit.Run 1 —
VERDICT command-exit 0 · held the lock 416s (6m56s) · waited 160s (2m40s)pnpm --filter '@objectstack/client^...' build(dependency closure first, on a fresh worktree)BUILD_EXIT=0pnpm --filter @objectstack/client exec vitest run --maxWorkers=2 src/client.test.tsTEST_EXIT=0—Test Files 1 passed (1),Tests 210 passed (210)pnpm --filter @objectstack/client typecheck(tsc --noEmit && check:test-typecheck)TC_EXIT=0— "@objectstack/client's test layer compiles under packages/client/tsconfig.test.json; 0 file(s) / 0 error(s) held in test-typecheck-debt.json"Run 2 — the reverse verification above, then the gate family. Per-gate results are posted in a follow-up comment on this PR.
Gate family re-derived against the actual change set with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed — the script takes its own change set from the merge base; the--repoassertion held against this checkout'sorigin). It reported the change set as the three paths in this PR and placed all 167 discovered families. Also run: the convention-triggered set for adds or edits a test file, and this lane's standing full-repopnpm lint(not narrowed).Scope, and what was left alone
packages/specread-only, ⛔packages/rest/src/rest-server.tsuntouched (held by feat(rest): mount the compound-name per-item promotion doorPOST /meta/:type/:section/:name/publish(#11932) #12105), ⛔ nocontent/docs/releases/edit, ⛔ no ratchet or debt-ledger edit. The whole diff ispackages/client/src/index.ts,packages/client/src/client.test.tsand one changeset. No governed surface is touched.needs:contract-reviewand it is hung here too. ⛔ Not draft-flipped, ⛔ no auto-merge armed — the gate is cleared by the review chain, and the verdict is recorded on the card, not on this PR.Filed out of scope
#12181 —
meta.deleteItemsends none of the three carriers the REST reset door reads: noIf-Match, no?state=draft, no?dropStorage, on either declaration. Same defect shape one method over, found while measuring this one; the siblingMetadataClient.resetalready sends the header and the state parameter, and the spec'sparentVersiondescribe text names the header on that exact door. Filed unlabelled and unassigned for triage rather than taken here — three separate widenings of the published surface are a ruling of their own, exactly as this card was reported out of #11391 rather than folded into it.Generated by Claude Code