Repository navigation
fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused (ADR-0029 D9.9) - #23510
Conversation
…runtime-authored row owns is refused (ADR-0029 D9.9) classifyObjectContribution asked only getPackagedObjectOwner, which answers for code artifacts, so a save naming package Q of an object package P's sys_metadata row owns was written and then refused by the registry's single-owner rule into a best-effort warn: 200 while sys_metadata held a row the registry could not hold. The classifier now also asks the registry's own contributor (getObjectOwner) and answers the same mismatch arm, so the save is refused with OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 before any write. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…hat the door refuses it The save door refuses a save into a second package of an object a runtime-authored row owns (ADR-0029 D9.9), so the fixture saves the first package's row through saveMetaItem and seeds the second directly, with the repository's own checksum. A fixture change, not a weakened pin. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…f a runtime-owned object Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…ntime-owner-overlay-refusal
…directly, now that the door refuses it Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
… label The change prescribes no rewrite of metadata or code, so the disposition is no-migration-prescription and the body says FROM/TO in bullets, as other accept-set narrowings do. Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm Co-authored-by: Claude <noreply@anthropic.com>
…ntime-owner-overlay-refusal
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 8 release-owned page(s) also name something this change touched. These are read-only:
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 8351f1e37ccb28c54a75b4e3c4c69dbcc468fa87 && git checkout 8351f1e37ccb28c54a75b4e3c4c69dbcc468fa87
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 55bb8619e73ad18a8a08a776e7bdee92bca25d03 && git checkout -B drift-repro f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4 && git merge --no-ff 55bb8619e73ad18a8a08a776e7bdee92bca25d03
node scripts/docs-audit/affected-docs.mjs --json f9b421716dd79df6c7c3a34bbd5a3a9f425e84f4
|
Contract reviewServed-tier: ① Derived judgmentsFace: a narrowing of the metadata save door's accept set for
② Semver level
③ Boundary flagsDev report
Check-runs on the head, read at 2026-10-14T15:44Z: 30 success, 6 skipped (the main-only battery and the opt-in smoke), 3 Implemented-by: VERDICT: PASS |
席位核对
Verdict: ACCEPT. Not governed (
|
…issue-23429-metadata-family One conflict: packages/rest/src/meta-delete-drop-storage-other-rows.test.ts — main parameterised the seed (seedStoredRow(packageId)); S2 drops the retired organization_id key from it. Took main's shape without the key. Auto-merged files both sides touch (main = S1/S3/#23489, kept as merged): organization-ownership.ts keeps S2's template-lookup column guard (S2's own hunk); organization-ownership-plan.ts and -inventory.ts are main's (S1) byte-for-byte; protocol.ts keeps #23489's packagelessReadAddress beside S2's legacy-row filtering; rest-server.ts keeps S2's deletion of the form doors' organization read; generated translations and the engine-double ledger are regenerated after the merge. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Fixes #23416
Clause-②: no
What this does
classifyObjectContribution(packages/metadata-protocol/src/protocol.ts) asked the registry one question,getPackagedObjectOwner, which answers for a CODE artifact only. An object that package P'ssys_metadatarow created at runtime has no packaged owner, so a save of it naming package Q classified asownand was written. The write-through then calledregisterObject(…, Q, …, 'own'), the registry's single-owner rule (ADR-0029 D3) refused it, andapplyObjectRegistryMutation's best-effortconsole.warnswallowed the refusal. The save answered200whilesys_metadataheld a Q-bound row the registry could not hold: the disagreement ADR-0029 D9.9 (overlayPackageMismatchError) exists to prevent.The classifier now asks one more question when there is no packaged owner: the registry's own
owncontributor (getObjectOwner(name), keyed on the object name exactly asregisterObjectkeys its contributors). When that owner is bound to a real package and the row's binding names a different package, it answers the existingmismatcharm. So the save door refuses with the code-owner path's refusal and prescription (OBJECT_OVERLAY_PACKAGE_MISMATCH/422), beforeensureOverlayIndexand everyput. One arm answers both owners; no second refusal path, no new error code, no new message.Unchanged by construction:
own, an ordinary re-registration (the owner's re-save stays200).own. A direct publish-mode save naming no package is addressed to the owner's row before this runs (packagelessAddress).sys_metadatasentinel (a package-less runtime object) or by no package: left to today's verdict. It names no package the prescription could direct a save to. See the acceptance notes.Premise check (the seat's ruling premise)
The ruling needed a runtime owner's package that can be read deterministically at that point of
saveMetaItem. It holds. The registry keeps exactly oneowncontributor per object name (registerObjectenforces it), and that contributor is the very factregisterObjectjudges an incomingownclaim against. The arm predicts the registry's own refusal and judges nothing else. The object write-through is not kernel-gated (applyRegistryWriteThrough's object branch), so every saved runtime object is in the registry on every kernel topology. Measured at the HTTP door on both topologies (below).Measured before the fix (real kernel, base
cb9841aa25)ObjectKernel+ObjectQLPlugin+ an in-memory driver,saveMetaItemasPUT /api/v1/meta/object/NAME?package=Qcalls it:qa_probe_gadgetsuccess: true, "Saved object 'qa_probe_gadget' (env-wide, package=com.qa.q, state=active)"com.qa.p,com.qa.qcom.qa.pregisterObject failed … already owned by package "com.qa.p" … cannot claim ownershipMeasured after the fix
OBJECT_OVERLAY_PACKAGE_MISMATCH/422(withpackageIdQ andownerPackageIdP). No row and no history entry is stored for Q. The registry andsys_metadataboth still say P. CONTROLS: P's own re-save answers success and the registry follows it; a save naming no package addresses P's row; a code-packaged owner answersNOT_OVERRIDABLE/403, as it did before this change (measured on the base).RestServerPUT /api/v1/meta/object/:nameroute over a real better-sqlite3 engine, control-plane and environment kernels): P200; Q422with bodycode: OBJECT_OVERLAY_PACKAGE_MISMATCHand the D9.9 sentence; P again200; stored bindings[com.probe.p].422, no draft row;duplicatePackageP → Q of an object whose name lacks P's namespace prefix (so the copy keeps the name): listed incopiedwith a Q row the registry never held → listed infailedwith the D9.9 text, nothing stored;loadMetaFromDb) with a stored Q row beside P's: P registered and the Q row counted inerrorseither way. The warn line changes fromcode=OBJECT_OWNERSHIP_CONFLICTtocode=OBJECT_OVERLAY_PACKAGE_MISMATCH.Fixtures re-judged (the rule's consumer radius)
Two suites built the two-package runtime shape by saving the second package's row through the door. Both are RE-SEEDED, not weakened: the first package's row is still saved through the door, and the second row is written straight into the store, as a row stored before this refusal existed.
packages/objectql/src/protocol-delete-object-registry-heal.test.ts,twoPackageObject(PR fix(metadata-protocol)!: a delete naming one package keeps the item's other rows registered, and a package-less draft save of an item two packages hold is refused 409 #23408's pin, per pointer6163309335). The seeded row is the first row's own shape, rebound and re-labelled, with the repository's checksum (hashSpec). The fixture asserts that formula on the row the door just wrote, and that the registry still holds the first package.packages/rest/src/meta-delete-drop-storage-other-rows.test.ts,twoPackageObject(PR fix(metadata-protocol)!: a delete with dropStorage drops an object's table only with its last stored row, refused 409 while another row remains #23464's pin, landed after the claim). Its existingseedUnboundRowis generalised toseedStoredRow(packageId). This file is a surface increment over the claim's declared surface: it is the PM's swept consumer radius, and the PR breaks it without the re-seed.Each re-seeded fixture was ablated against the behaviour it pins, to show it still bites (mutation through
scripts/ablation-replace.mjs,dist/proven byscripts/ablation-dist-preflight.mjs, restored blob equal toHEADand the tree clean):mismatchreturn replaced byown), new pinTests (consumer contract face)
All readings below are at this PR's head
55bb8619e7, which mergesorigin/mainat394f65e89. Each command ran under the shared verify lock (scripts/pm/os-verify-lock.sh), and each verdict is that wrapper'sVERDICT command-exitline.pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2pnpm --filter @objectstack/metadata-protocol typecheckpnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2pnpm --filter @objectstack/objectql typecheck(tsc +check:test-typecheck)pnpm --filter @objectstack/rest exec vitest run --project local --maxWorkers=2pnpm --filter @objectstack/rest typecheck(tsc +check:test-typecheck)The objectql suite's
repoproject, which holds the tests that read outside the package, is CI's (test:repo). The red-before reading for the new pin was taken at the base: 2 failed of 4. The refusal case answerednull(a success). The code-owner control then readNOT_OVERRIDABLE, and that measured value is what the control now pins.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackat55bb8619e7: 67 commands. All 67 ran at that head with exit codes recorded, and--ranreconciles: "67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN". Every command exited 0.check:type-check-debtfirst got lock exit 99 (its own PREREQUISITE NOT MET, exit 3) and passed when re-run under the lock: "1 ledger entr(ies) re-measured … none above its recorded number". On an earlier head,check-adr-0087-registrationrefused a## FROM → TOsection label besideno-migration-prescription. The changeset now states the FROM/TO as request answers, and the gate passes (not-required (no-migration-prescription)).CI owns everything the derivation does not place: the artifact-roster families, the wide-population families, the type-check lanes, Test Core, Build Core, dogfood and the temporal conformance job.
packages/runtimeandpackages/qa/dogfoodsuites were not run locally. A heuristic sweep of their tests for a second package's object save through the door found none.origin/mainhas moved past394f65e89by two commits (ad5a7d7c39spec page-action rows,111b5821cea runtime test). Neither touches this diff's packages or behaviour; CI's merge ref covers them.Acceptance notes
sys_metadatasentinel) behaves as this card did before the fix. On a real kernel atcb9841aa25, a package-less save then a?package=Qsave answered success with rows[null, com.qa.q], while the registry kept the sentinel owner and warnedalready owned by package "sys_metadata". The arm deliberately excludes it: the D9.9 prescription would tell the author to bind to'sys_metadata', which is not a package, and the right remedy (orphan adoption, or saving without a package) is a wording decision. Reported to the seat for the D9.9 family's close-out, not filed by this PR.duplicatePackagenow reports what it used to fake. A source object whose name lacks the source namespace prefix keeps its name in the copy. Before this PR that copy was a row the registry refused, over the same physical table (object name = table name). Now it lands infailed[]. The docblock ofduplicatePackagealready calls a same-name copy a collision. The prescription text it carries ("bind … to P, or have Q extend") is the code-owner path's and fits the save door better than the duplicate face; renaming the object is the remedy there.objectExtensions, which a code package declares. No runtime metadata door authors an extension. The ruled prescription is reused unchanged; the first remedy (bind to P) is always available.applyObjectRegistryMutationandloadMetaFromDbalready route amismatchto their warn and per-recorderrors. The heal comment inrestoreArtifactRegistryView("a second package's runtime-authored row never registered (its write-through was refused at save)") stays true of stored rows and sits in the delete heal thatdeleteMetaItemruns, outside this card's surface.Generated by Claude Code