Repository navigation
fix(metadata-protocol): one head row per /meta write address — a second unpinned package-less draft save is accepted (#22128) - #22185
Conversation
…ss draft and ?package=all (#22128) Red at the base: the second unpinned package-less draft save of a package-owned item answers 409 METADATA_CONFLICT, and ?state=draft serves version null while the draft exists. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…or the save's parent, the repository's lock and the read's version (#22128) SysMetadataRepository.headAt answers the row a put at the same address upserts, through the same resolution put's lock runs (resolveWriteHead), the #11087 draft package inheritance included. storedHeadAt asks it, so the save door's expected parent and the item read's served version are the row the lock judges. The /meta item read folds ?package=all through the save door's reading (metaItemPackageBinding, shared by GET, PUT and publish). SaveMetaItemRequestSchema.packageId states the inheritance; the publish route's description spells the wire code METADATA_CONFLICT. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…ibe; add the #22128 changeset Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…2128) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…aft-head-resolution
📓 Docs Drift CheckThis PR changes 3 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 142 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 cef9a8279bfce9ec8c5ed5a643a3471fcf6982d3 && git checkout cef9a8279bfce9ec8c5ed5a643a3471fcf6982d3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1e5d322c1e14803056c789c71d8ba87a538aa23c ea3c748f8c42f6e5a0266b405a33430bc9a70477 && git checkout -B drift-repro 1e5d322c1e14803056c789c71d8ba87a538aa23c && git merge --no-ff ea3c748f8c42f6e5a0266b405a33430bc9a70477
node scripts/docs-audit/affected-docs.mjs --json 1e5d322c1e14803056c789c71d8ba87a538aa23c
|
Contract reviewServed-tier: Reviewed on the PR head as read from the API (it equals the head the brief named; the PR is a draft of 6 commits, 9 files, +546/−103, base ① Derived judgments
② Semver levelThe changeset
The code is right; the declaration is what moves. Remedy, no code change: ③ Boundary flags
Implemented-by: VERDICT: FAIL One failing item, ②: the Reviewed 2026-10-08T05:32Z, read-only: the card, the PR, the compare diff and the head's check-runs. |
…e draft save overwrites (#22128) storedBodyForCarryForward read the stored body at the key the caller named. For a package-less draft save of a package-owned datasource that is neither the inherited draft nor the package-bound active row, so the comparison fell to the code layer and the stored credential was persisted away (measured on the real route). Both of its reads now go through SysMetadataRepository.headAt, which also answers the binding the write targets, so the active fallback reads the row the draft overlays. Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
…(widening) for the added headAt; the carry-forward bullet (#22128) Claude-Session: https://claude.ai/code/session_01RPo7FUd6bSnAfkWMAKi848 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed on the PR head as read from the API at 2026-10-08T06:28Z (it equals the head the brief named; the PR is a draft of 8 commits, 9 files, +664/−111, merge base ① Derived judgments
② Semver level
The previous record's one failing item is resolved by this head. ③ Boundary flags
Implemented-by: VERDICT: PASS The previous record's failing item is resolved and its escalation is measured, fixed and pinned on this head. ①, ② and ③ hold. One new item is escalated for measurement, pre-existing and outside this PR's class. Reviewed 2026-10-08T06:35Z, read-only: the card, the PR, the compare diff and the head's check-runs (read 06:28:47Z and 06:33:39Z). |
Fixes #22128
Clause-②: yes (widening)
The fix restores ADR-0008's stated behaviour: an unpinned save is last-writer-wins. The
packageIddescribe now states what the code already does.@objectstack/metadata-protocolisminor, becauseSysMetadataRepository.headAtis one added public method on an exported class. It is the only change to the package's public surface. The precedent is.changeset/21986-metadata-protocol-declines-stored-row-public.md: one added method,yes (widening),minor.@objectstack/restand@objectstack/specstaypatch.The contract review (
6053073603) found the earlier declaration ofnowrong on this one point. Triage'snocame before the fix had a shape. A widening is not a break, so no ADR-0087 disposition marker applies:check-adr-0087-registrationreads1 non-breaking changeset(s).Direction (triage
6049925243, binding)The seat's rider
6051335917adds two items:?package=allon the item read, and themetadata_conflictspelling in the publish route's description. Patch round 1 adds a third member of the class, which the contract review escalated: the credential carry-forward. See the section below.What was wrong (measured before the fix)
The new real-route test
packages/rest/src/meta-draft-head-package-inheritance.test.tswas committed first (eb42ce01e). It ran against adist/built at the base7ef50a4fb, which already carries PR #22126:The one case that passed is the env-local control, which is meant to pass both before and after.
The three readers resolved three different rows:
saveMetaItem, throughstoredHeadAt)repo.getat the key it was handed. With no package, that is the package-unbound row (package_id IS NULL).repo.headAt, which runsresolveWriteHeadversion(readVersionToken, throughstoredHeadAt)repo.get. The REST read door forwarded?package=allverbatim, so the key waspackage_id = 'all'.repo.headAt.allis folded first, as the save door folds it.SysMetadataRepository.put)resolveWriteHead, the same code moved into one functionThe change
SysMetadataRepository.resolveWriteHead(private,packages/metadata-protocol/src/sys-metadata-repository.ts). For a write ofstatethat namespackageId, it returns the row the write upserts (nullfor a create) and the binding a create stamps. It contains SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087's draft inheritance, which reads the active row across the caller's org and the env-wide scope, and the orphan-draft adoption, both unchanged. It lives in the repository becauseput's lock and stamp already live there, and the protocol already calls the repository. Calling it from the protocol adds no new layer dependency. Keeping it in the protocol would have made the repository call up into its caller.putcalls it inside its transaction, for the lock and the stamp. The inheritance lookup now runs inside that transaction too. It used to run just before it.SysMetadataRepository.headAt(ref, { state, packageId })is the new public method. It answers{ head, packageId }.headis the row the write upserts, served asgetserves a row, so itshashis the versionlockAcceptsaccepts.packageIdis the binding that write targets.ObjectStackProtocolImplementation.storedHeadAtnow asksrepo.headAt, notrepo.get. Its callers are unchanged:saveMetaItem's expected-parent read, for both thedraftand thepublishmode. Unpinned, the parent is the head. Pinned, the token is judged against the head (storedParentForToken).readVersionToken, the item read'sversion, for both?state=draftand the uncached plain read.storedBodyForCarryForward(patch round 1). Both of its reads now askrepo.headAt: the row the save overwrites, and with no draft yet the active row in the package the draft is stamped into.metaItemPackageBinding(raw)(packages/rest/src/rest-server.ts) is the one reading of?package=on the three/meta/:type/:namedoors. Before,PUTandPOST …/publisheach had an inline copy, andGEThad none.alland the empty value name no package.GET's cache bypass (packageScoped) still reads the raw parameter, so?package=allkeeps the uncached arm and keeps servingversion.SaveMetaItemRequestSchema.packageIdnow states the inheritance. It also states that the same row is the one the parent-version resolution reads and the one the item read serves asversion. The protocol reference is regenerated (gen:docs, throughcheck:generated --fix).POST /:type/:name/publishroute description now says 409METADATA_CONFLICT.GetMetaItemResponseSchema.version's describe (from meta(/meta door): the draft read serves no version token, so no client can pin its first draft save with If-Match; the 409 METADATA_CONFLICT body names the current version only in prose #22114) was re-read against the fix and still holds: "the STORED row a save to this item compares against, at this read's scope". It is unchanged.One behaviour moves on
GET /meta/:type/:name?package=all: the served content. Before,allwas a package address, so the read preferred a row bound to a package literally namedall, then the package-less row. Now the read serves what a read with no?package=serves. That includes the draft the package-less draft save stored in the item's package, which before answered404 NO_DRAFT. objectui's editor sends this read (read at objectui9990f9e):ResourceEditPage.tsxscopes its draft and layered reads with the router's raw?package=, which does not foldall(its own docblock says so), while its saves fold it.The credential carry-forward (patch round 1)
The defect. A save of a type whose read withholds a credential carries the stored credential back into a body that returns as it was served. Today those types are
datasource(built in) andflow(with the automation plugin). The carry-forward is #8154's write-path inverse.storedBodyForCarryForwardcompared the served body againstrepo.getat the key the caller named. For a package-less draft save of a package-owned item, neither the inherited draft nor the package-bound active row sits at that key. So the comparison fell to the code layer, nothing was carried, and the stored credential was persisted away.Measured on the real route (the rest harness above, better-sqlite3), at head
07c229054, before this round's fix:config.urlholds a userinfo credential. It was written straight to the store, because the save door now refuses such a URL, so a row like this only exists from before the gates.GET /meta/datasource/warehouseserves it redacted.PUT …?mode=draftand no?package=, twice: 200, 200.config.urlwith no credential.meta-draft-head-package-inheritance.test.tswent1 failed | 8 passed (9)on the pre-fixdist/. The failure isexpected [ false ] to deeply equal [ true ], on whether the first stored draft holds the credential. With the fix andmetadata-protocolrebuilt (grep -c 'target.packageId' dist/index.js→ 1), the file is green. Neither the PR body nor any comment carries a credential value: the pins assert a boolean.The fix is the same resolution (
4e63f1122).storedBodyForCarryForwardreadsrepo.headAtat the save's own state. With no draft yet, it readsrepo.headAt(active, packageId)with the binding that call answered. The code-layer fallback and its#20552reason are unchanged. An active save's carry-forward, and the authoring gate'srestoredCredentialPathsFor, read the exact key as before, becauseheadAtadds nothing foractive.Pins
packages/rest/src/meta-draft-head-package-inheritance.test.ts: the realRestServerroutes, better-sqlite3:memory:, the realsys_metadata*objects. Each pin reads the store back.the measured sequence: active save in a package, a package-less draft, the same draft again with no If-Match → 200, 200, 200?state=draft after the package-less draft serves that draft's version (the save receipt's), not nullcontrol: a stale If-Match is still refused 409, with the inherited draft's token as currentVersion. It also coversIf-None-Match: *refused while the inherited draft exists.control: an env-local item with no package is unchanged — unbound rows, unpinned saves accepted, the read serves the receipt's token?package=all):active: the read's version is the token the save compares against; If-None-Match: * is refused only because a row is thereactive, no row at the env-local address: the read serves null, and If-None-Match: * creates itdraft of a package-owned item: ?state=draft&package=all serves the inherited draft and its token, and the save accepts it[#22128] the credential carry-forward reads the row the draft save overwrites:package-owned: the redacted read saved back as a package-less draft, twice, keeps the stored credentialcontrol: env-local, the same round trip keeps the stored credential, as beforepackages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts, new describeSysMetadataRepository.headAt — the row put locks against (#22128), 6 cases. They also pin thepackageIdbindingheadAtanswers.getat the named key is not,putrefuses parentnulland accepts the head's version;The #11087 inheritance pins in that file are unchanged and green.
Tests and gates
Every reading below is at head
ea3c748f8(patch round 1).origin/mainwas not merged again, because the branch still merges cleanly. No file of this diff overlaps the 11 commitsmainhas gained since959c209d5.@objectstack/metadata-protocolwas rebuilt before the real-route runs, because@objectstack/rest's tests resolve it throughexportstodist/.meta-draft-head-package-inheritance.test.ts,meta-item-version-token-occ.test.tsandmeta-publish-package-scope.test.ts→Test Files 3 passed (3) · Tests 42 passed (42), run at4e63f1122, whose codeea3c748f8carries unchanged. That covers this card's 9 pins plus meta(/meta door): the draft read serves no version token, so no client can pin its first draft save with If-Match; the 409 METADATA_CONFLICT body names the current version only in prose #22114's OCC pins and thePUT/publish?package=parity pins.6 failed | 1 passed, as quoted above.1 failed | 8 passed (9), as stated in its section.@objectstack/metadata-protocol.vitest run --maxWorkers=2→Test Files 221 passed | 3 skipped (224) · Tests 28259 passed | 19 skipped. That includesprotocol.metadata-redaction.test.tsandprotocol.credential-channel.test.ts.typecheck→ exit 0.@objectstack/rest.vitest run --project local --maxWorkers=2→Test Files 262 passed (262) · Tests 4938 passed | 326 skipped.typecheck(tsc --noEmit && check:test-typecheck) → exit 0.@objectstack/objectql. The 54 test files that namesaveMetaItem/getMetaItem/SysMetadataRepository→54 passed · 683 tests passed.protocol-meta.test.ts'sfindOnepins are among them.@objectstack/spec(descriptions only, no source change since round 0).07c229054: the 4 test files that nameSaveMetaItemRequestSchemaor the REST route table →4 passed, andtypecheck→ exit 0.check:generatedwas re-run at this head and is green.check-changeset-no-majorwas run with apull_requestevent carrying this body. It answeredLEVEL AXIS: this PR declares clause-② yes (widening), and it grades a package whose packages/**/src/** it moves at minor or above, exit 0.check-adr-0087-registration→1 non-breaking changeset(s), exit 0.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatea3c748f8derived 113 commands, the same set as round 0. Each was run with its exit code captured before any pipe.--rangives:113 derived famil(ies) accounted for — 111 run, 2 NOT-MEASURED.pnpm --filter @objectstack/spec run check:skill-examples(noclient-reactdist);pnpm check:dual-build-cjs-loads.origin/main. Two gate-source files differ there (scripts/engine-double-contract.pinned.json, which adds no entry for this diff's files, and a removed audit probe).check:error-code-provenance,check:error-code-casing,check:engine-double-contract,check:spec-changesandcheck:meta-url-spelling. 45 exit 0. The three PR-context gates arecheck-partof-closing-keyword,check-closing-target-claimandcheck-single-claim-paths. They were run withPR_NUMBER=22185and this body, and each exits 0.Acceptance notes
Each item below is noted, not filed.
The same
?package=allreading on three more/metadoors (measured). On the real stack, with a view whose active row is stored in packagecom.probe.pkg:GET /meta/view/case_grid/layers→ 200, overlaylive.GET /meta/view/case_grid/layers?package=all→ 404.GET /meta/view→["case_grid"].GET /meta/view?package=all→[].The layered read (
rest-server.ts,layeredPackageId = req.query?.package || undefined), the list read and the book lookup each forward the literalall. objectui's editor (ResourceEditPage.tsxat objectui9990f9e) scopes its layered and draft reads with the router's raw?package=. These doors are outside this card's declared surface, so they are not changed here. The seat filed them as finding(rest):?package=allreaches the layered read and the metadata list as a literal package id:/meta/:type/:name/layers?package=allanswers 404 andGET /meta/:type?package=allanswers [] for an item stored in a package #22188.metaItemPackageBindingis the fold they would share.Lowercase
metadata_conflictelsewhere. The spelling persists inpackages/client/src/index.ts(four docblocks),packages/cli/src/commands/meta/delete.ts:115(a description string),content/docs/concepts/metadata-lifecycle.mdx:147anddocs/qa/platform-checklist/areas/studio-authoring.json:374,376. The rider named only the route description.sys_metadata_audit'scode: 'metadata_conflict'is the audit vocabulary's data value and is correct as it stands.put's draft-inheritance lookup now runs inside its transaction, beside the existing-row read. It used to run just before the transaction. The predicates are unchanged.Generated by Claude Code