Repository navigation
fix(metadata-protocol)!: a save naming a package of an object its package-less definition owns is refused, with a way out into that package (ADR-0029 D9.9) - #23704
Conversation
…package-less definition owns is refused (ADR-0029 D9.9) classifyObjectContribution now judges a runtime owner keyed by the 'sys_metadata' sentinel as it judges one bound to a real package: a row bound to package Q beside it is a mismatch, refused before any write with OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 and a prescription that moves the object into Q and keeps its table and records (delete the package-less definition without dropStorage, then save naming Q). Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…ect hydrated at boot (fallback metadata and MetadataPlugin) Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…lause-② no (narrowing)) Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…eck:query-options-erasure) Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…opt-orphans as a second door that sweeps every package-less row Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 11 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 2c92a8ebb5cebdc362e978f10ef5815bf45fb44b && git checkout 2c92a8ebb5cebdc362e978f10ef5815bf45fb44b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9bb93706fbdf57582329745b64c2f40c6a084572 9a259bd7d21e122543a6c178d8f4fbccd45aedb7 && git checkout -B drift-repro 9bb93706fbdf57582329745b64c2f40c6a084572 && git merge --no-ff 9a259bd7d21e122543a6c178d8f4fbccd45aedb7
node scripts/docs-audit/affected-docs.mjs --json 9bb93706fbdf57582329745b64c2f40c6a084572
|
Contract reviewServed-tier: Read: card #23514's body and all nine comments (6172542441, 6174591274, 6187670810, 6188859508, 6188959051, 6191875410, 6195283743, 6195318252, 6197627715); PR #23704's body, its three files, and the net diff ① Derived judgments
② Semver level
③ Boundary flags
Owed for a PASS. It is narrow, and the card's door does not change:
Implemented-by: VERDICT: FAIL |
Seat answer to the contract review 6198087441 (FAIL at
|
…true for a draft save and for rows stored before it A draft save naming Q is told to move the object with a publish-mode save naming Q first. Where a row bound to Q is already stored beside the package-less one, the refusal (save door, boot, write-through) names deleting that row by naming Q first, and a save naming no package addressed to it is told so. Pinned on a real kernel across a boot. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…oot reason Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…e-less owner names the deletes, then the same publish Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read: card #23514's body and all ten comments (6172542441 through the dev's round-3 report 6199186382); PR #23704's body as the seat refreshed it at 18:25Z, its three comments (above all the round-2 record 6198087441 and the seat's answer 6198103715), its three files, and the net diff
I counted the message lengths from the literals in the diff. The head has 47 check-runs: 39 success, 8 skipped, 0 failed, 0 in progress. The skipped ones are five by path filter, the opt-in tarball smoke, and Auto Label and Check PR Size on the 18:26 body-edit event; those two were success at 17:53. Test Core 1–6, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard and both Check Changeset runs are success; the second Check Changeset run followed the body edit. Governing text: the seat rulings 6188959051 (refuse with a real way out; option B not taken) and 6198103715 (the way out true in every state the refusal answers, four owed points); the card's Wanted; and ADR-0029 D3 and D9.9 and ADR-0070 TL;DR 1 as the diff and the rulings quote them. ① Derived judgments
② Semver level
③ Boundary flags
Owed for a PASS. It is narrow, and the card's door and every pinned state stay as they are:
Incidental, not contract: Implemented-by: VERDICT: FAIL |
Seat answer to the contract review 6199542688 (FAIL at
|
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…'s stored row by its own delete A delete naming no package resolves to the one package binding present, or is refused 409 over two, so the way out names every row bound to a package (not only the named one) by ?package= before the package-less delete, at the save (publish and draft), promote, boot and write-through doors. The texts put every step in the REST message bound. UnboundOwnerRefusalState moves above HydrationOptions' doc. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…nd to any package Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read: card #23514's body and all eleven comments (6172542441 through the dev's round-4 report 6200528804); PR #23704's body as the seat refreshed it at 20:12Z, its five comments (above all the round-3 record 6199542688 and the seat's answer 6199585504), its three files, and the net diff ① Derived judgments
② Semver level
③ Boundary flags
Owed for a PASS. It is narrow, and the card's door and every pinned state stay as they are:
Implemented-by: VERDICT: FAIL |
Seat answer to the contract review 6201547904 (FAIL at
|
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
… draft adopted into the one binding names where its steps land Measured: once that package's row is deleted, the publish lands the draft in the package-less definition (a publish naming the package answers NO_DRAFT), so the text says so and names the move into the package as the next step. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…l-owner refusal Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read: card #23514's body and all twelve comments (6172542441 through the dev's round-5 report 6202676437); PR #23704's body as the seat refreshed it at 22:12Z, its seven comments (above all the round-4 record 6201547904 and the seat's answer 6201560378), its three files, and the net diff
Governing text:
① Derived judgments
② Semver level
③ Boundary flags
Owed for a PASS. It is narrow; the card's door, the adopted arm and every pinned state stay as they are:
Implemented-by: VERDICT: FAIL |
Seat answer to the contract review 6203512210 (FAIL at
|
Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
…to a package names it in its publish step A publish naming no package resolves by the rows the deletes leave, and can then address another draft (packagelessDraftAddress). The step is now the request itself, POST .../publish?package=PKG, measured over every combination of stored rows, drafts and naming at the promote door. Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
… publish step Claude-Session: https://claude.ai/code/session_01TkXMn8bJqX6ZgMRwuZTxpf Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Read: card #23514's body and all thirteen comments (6172542441 through the dev's round-6 report 6204057324, its
I counted the promote texts' lengths from their literals. That was a string-length count; no repository code was run. The head has 47 check-runs: 39 success, 8 skipped, 0 failed, 0 in progress.
Governing text:
① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | PR → main. Line 1 is Fixes #23514, the only closing keyword. #23630 is named without one (its member 2 is pinned here). |
| Scope | 3 files (+1129 / −7): packages/metadata-protocol/src/protocol.ts (classifyObjectContribution's sentinel arm; unboundOwnerPackageRefusal and its private UnboundOwnerRefusalState; the state passed from saveMetaItem's D9.9 call, the promote door, the boot hydration and applyObjectRegistryMutation), a real-kernel pin file of 15 cases in packages/objectql, and the changeset. That is the claim's surface (6195283743). No governed path (check-governed-merges.mjs --pr 23704: 0 of 3). Under the 3,000-line threshold. |
Clause-② |
no (narrowing) in the PR body and the changeset (the claim's no, with its arm). @objectstack/metadata-protocol minor BREAKING, !, ADR-0087 not-required (no-migration-prescription). The save door refuses where it answered 200. The changeset names every arm FROM → TO, with a remedy per state. |
| Contract review | Six rounds at TIER_DEFAULT (declaration face). Rounds 1–5 FAILed, each on a refusal text whose steps did not reach what it said in one more stored-row state (6198087441, 6199542688, 6201547904, 6203512210). The round-5 ruling 6203522045 closed the class: every promote step names the draft's package, and the promote door's matrix is measured once. Round 6, 6204276537: PASS at this head. The reviewer checked all 36 matrix cells against the code. The seat checked the load-bearing change itself: both package-bound promote arms end with POST …/publish?package=Q (Q the draft's own binding); the adopted-draft arm and the save-door texts are unchanged. |
| Measured | The ruling's premise was measured before shipping in round 2: the two-call way out holds for an object hydrated at boot too, after #23659. HTTP walks on dev:crm across restarts were done in rounds 2 and 3. In round 6, the 36-cell promote matrix was walked from each refusal's own text across a boot: 10 refused, all landing where their text says; the other 26 not refused. |
| Pins and controls | 15 real-kernel cases across a boot: the card's sequence; #23630 member 2 ([P, Q, package-less] cannot be produced); both walks (in-process and boot-hydrated, fallback metadata and MetadataPlugin); the draft door; legacy [package-less, Q], [package-less, P] and [package-less, P, Q]; the promote door; the adopted-draft arm; and the CONTROL (a save naming no package over a package-less-only owner is unchanged). The round-6 ablation (the unnamed publish step put back) turned exactly the 2 stored-rows promote pins red; the restore is green. |
| Gates | The dev's derived union at this head: 67 derived, 67 exit 0, --ran reconciled. CI on this head: 47 check-runs, 39 success, 8 skipped (the path-filtered roster, plus the body-edit re-runs of Auto Label / Check PR Size beside their success runs), none failed. Neighbours green beside these pins: #23683's promote pin, #23706's draft-door pins, #23716's metadata-copy pin and #23724's adopt-orphans pins. |
| PR body | Refreshed by the seat from each round's listed edits; last at 23:08Z. |
Disposition:
out_of_scope_findings: the draft save naming no package over legacy rows is metadata-protocol: over legacy object rows [package-less, Q], a draft save naming no package answers 200 into Q's draft, which no publish can serve: the save door's D9.9 check judges the request's package before the draft address resolves to Q #23712 (filed,pm:blockedbehind this card). In 4 matrix cells an unbound draft is left stored after the walk; by the draft doors' design it then becomes P's pending draft. That is an Acceptance note, not filed: the draft doors' address is ruled (metadata-protocol: the draft doors naming no package resolve three addresses — the save and read inherit the active binding, the discard reads the draft rows, the publish takes the first draft row a package-agnostic findOne returns #23495, metadata-protocol: a draft save naming no package, of an item never published into a package whose only draft is bound to package Q, writes a package-unbound draft beside it, while the publish and discard naming none act on Q's (after PR #23654) #23657), and nothing is served or lost. The 409 that a no-package save gets over two bindings converges with this refusal, as measured; it is noted.- The incidentals: the doc-table row and one pin title still say "then the same publish". They are not contract, and they ride the next edit of that file.
- metadata-protocol: two producers still store a second package's active row of an object that a runtime-authored row owns — a draft promoted after the owner's save, and a package-less owner — past ADR-0029 D9.9's save-door refusal (family close-out) #23630's ruling 6187770366 (member 2 rides objectql/metadata-protocol: a save naming a package of a runtime object the registry holds under the sys_metadata sentinel answers 200, but the registry keeps the package-less definition, so the saved row never goes live #23514's merged PR): met. The pin "saves naming P then Q are both refused, so
[P, Q, package-less]cannot be produced" is in this PR.
Generated by Claude Code
Fixes #23514
Clause-②: no (narrowing)
#23630 member 2's sequence (the package-less owner,
[P, Q, package-less]) is pinned here. PR #23683 (member 1) closed #23630 on the seat's condition that this PR pins member 2.What changed
classifyObjectContribution(packages/metadata-protocol/src/protocol.ts) now judges a runtime owner keyed by thesys_metadatasentinel as it judges one bound to a real package. A save naming any packageQof an object whose registry owner is its package-less definition is amismatch, sosaveMetaItem's existing ADR-0029 D9.9 call refuses it before any write, with the existing codeOBJECT_OVERLAY_PACKAGE_MISMATCH/422and no new error code.overlayPackageMismatchErrorhands the sentinel owner to a new privateunboundOwnerPackageRefusal, because D9.9's prescription ("bind it to the owner's package") would read "bind it tosys_metadata", and the package-less state is no authoring destination (ADR-0070 TL;DR 1). The refusal names the measured way out, prescription first:The error carries
packageId: QandownerPackageId: null. D9.9's refusal for an owner bound to a real package is unchanged (same code, status, text).This resumes
eca860d386(the earlier dev's committed, never-shipped refusal) merged withmain. What changed since that sha: twomainmerges (no conflict: #23626's sealed-write refusal and #23654's draft addressing sit above the D9.9 call and were left asmainhas them); two new boot-hydrated pins; the pin's read options typed (check:query-options-erasure); the code header's note onadopt-orphansrewritten, since PR #23688 (merged after the first merge) makes it hand an adopted object's owner toQin the same request; and the changeset.The ruling's premise, measured before shipping
The seat's route was conditional: refuse only if the two calls (delete without
dropStorage, then save namingQ) are a real way out for an object hydrated at boot too. Measured after #23659's delete heal, on the merged branch:Protocol level, real
ObjectKernel+ObjectQLPlugin+ memory driver, a second kernel over the same store as the boot, on the kernel's fallback metadata AND withMetadataPlugin(NodeMetadataManager,watch: false, the composition whose boot-hydrated delete failed before #23659): seed package-less + 2 records, reboot, save namingQrefused422, delete (no package, nodropStorage) → the registry owner,getObject,getMetaItemand the data plane (OBJECT_NOT_FOUND/404) all say gone at once, table keeps 2 records; save namingQ→ ownerQ, data plane and metadata read serveQ's definition, 2 records; insert a 3rd; reboot → ownerQ, 3 records, rows[Q], nothing dropped. Pinned (two cases).HTTP, full composition (
pnpm dev:crm -- --database file:SCRATCH/crm.db -p 38514, a persistent SQLite file andOS_HOMEso a restart is a real boot; seeded admin session):PUT /api/v1/meta/object/qa_probe_gadget(no package) →200; twoPOST /api/v1/data/qa_probe_gadget→201,201.PUT …?package=com.qa.q→422OBJECT_OVERLAY_PACKAGE_MISMATCH, the message above;GETmeta still "Gadget unbound", data2002 records.GETmeta "Gadget unbound", data 2 records.PUT …?package=com.qa.q→422again.DELETE /api/v1/meta/object/qa_probe_gadget→200"Deleted object 'qa_probe_gadget' — it no longer exists." Then metaGET→404, dataGET→404OBJECT_NOT_FOUND(gone at once).PUT …?package=com.qa.q→200"Saved object … package=com.qa.q". Meta "Gadget Q", data2002 records (kept).POSTrec-3 →201, 3 records.Registered object: qa_probe_gadget (own, priority=100) from com.qa.q. Meta "Gadget Q", data 3 records.PUT …?package=com.qa.q(owner's re-save) →200, live ("Gadget Q2").H3 (
adopt-orphans): PR #23688 landed onmainduring this run. Its own pins (protocol-adopt-orphans-object-registry.test.ts, including "a save naming Q after the adoption goes live") run green beside this refusal on the merged head: after an adoption the owner isQ, so the save classifiesownand is not refused. The prescription does not nameadopt-orphans: it rebinds every package-less row of the environment, not only this object; the delete-then-save pair is the narrowest way out.Pins
packages/objectql/src/protocol-sentinel-owner-package-save-refusal.test.ts(real kernel; 6 cases):Q's save is refused (code+status+packageId+ownerPackageId+ the first sentence), nothing stored forQ(rows and history), registry / data plane / metadata read agree on the package-less definition, before and after a boot, and the refusal holds after the boot;PthenQare both refused, so[P, Q, package-less]cannot be produced, across a boot;Q, owner and served definitionQ, records kept, across a boot;MetadataPlugin(above);Negative control (one-time ablation, not a permanent case)
objectqlreadsmetadata-protocolthroughdist/(aKNOWN_UNALIASED_TEST_IMPORTSpair), so each leg rebuilt and ran the preflight. Fix blob at HEAD1d7f77e50f53.ablation-replacerefused ("the anchor count moved 1 -> 1") and restored; nothing was read from it.&& runtimeOwnerPackageId !== 'sys_metadata', spelled with a marker) viascripts/ablation-replace.mjs: anchor1 -> 0, blob1d7f77e50f53 -> 3dd4bee907ce; rebuild exit 0;ablation-dist-preflight: marker present indist/index.jsanddist/index.cjs. Pin file: 5 failed | 1 passed (the CONTROL stays green by design). Expected red, observed red.git checkout HEAD --, blob1d7f77e50f53== HEAD,git diff HEADempty; rebuild; preflight--absent: marker absent from all 24 built files, tree clean; pin file 6 passed.Local verification (final head
62f6c360f)All runs through
scripts/pm/os-verify-lock.sh, eachVERDICT command-exit 0:@objectstack/metadata-protocolfull suite, 3 shards (--maxWorkers=2): 80 + 78 (2 skipped) + 78 (1 skipped) files passed; 1340 + 17832 + 1388 = 20560 tests passed, 19 skipped.@objectstack/objectqlfull suite (--project local):Test Files 426 passed (426),Tests 8140 passed (8140).typecheckformetadata-protocolandobjectql: exit 0;check:test-typecheckOK, debt unchanged (39 files / 233 errors / 64 pinned signatures).protocol-adopt-orphans-object-registry,plugin-runtime-object-metadata-copy,protocol-runtime-owner-overlay-refusal,protocol-object-overlay-layer,protocol-boot-object-package-binding,protocol-delete-boot-hydrated-object-heal,protocol-delete-object-registry-heal,protocol-package-lifecycle,protocol-writepath-object-ownershipand this pin: 10 files, 82 passed;metadata-protocol'sprotocol.object-registry-write-through-spelling: 12 passed.Gates:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat62f6c360fderived 67 commands (3 paths vs merge base3bc407d30); all 67 ran on that head with exits captured before any pipe: 67 × exit 0.--ran: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero). Two gates first refused on the shallow clone (check-engine-split-ratio.mjs --days 90,check-plugin-teardown-shape.mjs --self-test) and passed aftergit fetch --shallow-since=2026-07-10 origin main.check:query-options-erasurewas red atd22a2f79(test surface 236 → 238 from the pin'sas anyread options) and is fixed in0afedaba2.check-adr-0087-registration:[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).Round 2 (contract review 6198087441): the way out in every state the refusal answers
POST …/publish?package=Q(the publish names the draft's package since Round 5).Round 3 (contract review 6199542688): every package's stored row is named by its own delete
packagelessAddress(…).candidates), not only the named Q. Every text (save in publish and draft mode, the promote doors, the boot and the write-through) namesDELETE …?package=PKGfor each such row, thenthe same DELETE with no package and no dropStorage, then the save naming Q in publish mode (or, at the promote doors,POST …/publish?package=Q).[package-less, P]the no-package DELETE removes P's row ("(package=com.qa.p), and it still exists"); over[package-less, P, Q]it answers409 RESOURCE_CONFLICTand deletes nothing. The named walk converges across a boot: owner Q, records kept, nothing dropped.[package-less, P]with a save naming Q;[package-less, P]with a draft save and a publish of Q's draft;[package-less, P, Q], with the 409 asserted). Ablations: the state read 7 failed / 6 passed; Q-only keying put back 3 failed / 10 passed (exactly the new pins); restore 13 passed. Gates at fd249ae: 67 x exit 0, --ran derived zero.Round 4 (contract review 6201547904): the adopted package-less draft
key: null,into: P) and refused by D9.9. Measured: no publish moves that draft into P. AfterDELETE …?package=Pthe same publish lands it in the package-less definition, and a publish naming P answers404 NO_DRAFT.DELETE …?package=P, then publish again (the draft lands package-less), then the package-less DELETE and a publish-mode save naming P. At most 489 characters, inside the REST bound.Round 5 (contract review 6203512210): the promote door's publish step names the package
POST /api/v1/meta/object/NAME/publish?package=PKG. A publish naming no package resolves by the rows the deletes leave, and over[package-less, P]with both P's draft and an unbound draft it would promote the unbound one. The adopted-draft arm (a draft bound to no package) is unchanged.Acceptance notes
Qowns resolves toQ's row (packagelessAddress, by design) and is unchanged here.applyObjectRegistryMutationis now async; its one caller (applyRegistryWriteThrough) awaits it, and only its mismatch branch awaits a read (the stored package bindings its text names). Its best-effort warn is unchanged; for the object save door the warn is now unreachable for a sentinel owner, as it already was for a real-package owner.[package-less, Q], or rows bound to any package) are not rewritten. A boot still keeps the package-less owner and counts each package's row in errors, but its reason is now the move for those rows, naming every package's row by its own delete (see Round 2 and Round 3), not the registry's ownership text.Body refreshed 2026-10-15T23:08Z by
domain:engineseat 1 from the dev's round-6 report 6204057324 (its three listed edits), over the earlier refreshes.Generated by Claude Code