Repository navigation
fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) - #23683
Conversation
The measured sequence per door: a draft bound to package Q saved before package P's runtime-authored row of the object existed, then promoted by the per-item publish or POST /packages/Q/publish-drafts. Controls: a promote with no second-package owner, the owner's own draft, and the per-item publish's adoption of a package-unbound draft into the owner's package. Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
…wing) Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
… which the publish path writes Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
…on the save door asks promoteDraftForPublish, which both the per-item publish and publishPackageDrafts promote through, now refuses an object draft whose promotion would write an active row bound to a package that does not own the object, with the save door's judge (classifyObjectContribution) and envelope (OBJECT_OVERLAY_PACKAGE_MISMATCH / 422), before repo.promoteDraft writes. Judged on the package the row is written under (promoteInto). Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 f9474871ccae427226116481a5a2c5b5fc0bcc66 && git checkout f9474871ccae427226116481a5a2c5b5fc0bcc66
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3bc407d3072565e620711d8ad2a715c8922e2610 ea873b091f2297c29653d2c39ab613cae4ccfba5 && git checkout -B drift-repro 3bc407d3072565e620711d8ad2a715c8922e2610 && git merge --no-ff ea873b091f2297c29653d2c39ab613cae4ccfba5
node scripts/docs-audit/affected-docs.mjs --json 3bc407d3072565e620711d8ad2a715c8922e2610 |
Contract reviewServed-tier: What I read. Card #23630: its body and all four comments (triage 6187308981, block ruling 6187770366, claim 6191498700, dev report 6194366595). PR #23683: its body, its three-file list, and the net diff against What I judged against.
① Derived judgments
② Semver level
Clause-②: no (narrowing) — confirmed. ③ Boundary flags
Implemented-by: VERDICT: FAIL |
Seat answer to the contract review 6195338637 (FAIL at
|
…RIDABLE / 403, per door (red first) Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
…ed (NOT_OVERRIDABLE / 403) Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
… sealed managed content An object a code package ships is sealed (ADR-0131 D6): the save door answers its package door, NOT_OVERRIDABLE / 403, ahead of D9.9, and the promotion reaches the same refusal at the store. The new promote-door check asked classifyObjectContribution with no sealed-item test in front, so its packaged mismatch arm answered 422 OBJECT_OVERLAY_PACKAGE_MISMATCH with a prescription the sealed rule refuses. Guarded with isSealedManagedItem, per the seat's route (A). Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: What I read, and the text I judged against. I read these sources:
I judged against:
① Derived judgments
② Semver level
Clause-②: no (narrowing) — confirmed. ③ Boundary flags
Implemented-by: VERDICT: PASS |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | PR → main. The first line is Fixes #23630. A closing-keyword scan of the whole body finds one hit, line 1. #23514 is named only as carrying member 2. |
| Scope | 3 files (+459): packages/metadata-protocol/src/protocol.ts (promoteDraftForPublish only), the changeset, and one new packages/runtime test. That is the claim's surface (6191498700). The packages/runtime test is declared to domain:cli on #6024 (6197721284). No content/docs/releases/ edit, no governed path, under the 3,000-line threshold. |
Clause-② |
no in the claim and the PR body; no (narrowing) in the changeset. The two promote doors refuse a row they wrote before. It is a narrowing, and the changeset is BREAKING with the ADR-0087 marker not-required (no-migration-prescription). |
| Contract review | Round 1, 6195338637: FAIL (①4, the code-packaged owner answered 422). The seat ruled route (A) in 6195354019. Round 2, 6197699504, at this head: PASS, TIER_DEFAULT (the declaration face; tier question #23692). The seat checked the guard hunk itself: !this.isSealedManagedItem(singularType, request.name) sits in front of the classifyObjectContribution call. |
| Pins and controls | 8 real-composition cases. Both doors' runtime-owner refusals are pinned, and per-door code-packaged CONTROLs keep NOT_OVERRIDABLE / 403. There are three further CONTROLs. Two ablations (the check, then the guard) went red in the predicted place. |
| Gates | The dev's derived union at this head: 65 of 65 exit 0, with --ran reconciled. CI on this head: every check has concluded, none failed. Check PR Size and Auto Label show a skipped re-run from the seat's body edit, beside their success run. main's full battery is green at 3bc407d30 (run 38765432828). |
| PR body | Refreshed by the seat from the dev's round-2 text (the role file keeps body edits with the seat). |
Disposition:
- Member 2 (the package-less owner) 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: its claim 6195283743 and dispatch 6195318252 pin
[P, Q, package-less]. Per 6187770366, this seat reopens 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 if 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 does not pin it. out_of_scope_findings: theapplyObjectRegistryMutationnote goes to Acceptance notes; it still holds as written.- The reviewer's older ordering point (the promote door runs its other gates before the store's sealed refusal) predates this PR, is outside the card, and is not a ground. Not filed.
Generated by Claude Code
|
Landed ·
Status: #23630 is closed, and its Generated by Claude Code |
Fixes #23630
Clause-②: no
What this does
The save door refuses an
objectrow bound to a package that does not own the object (ADR-0029 D9.9,overlayPackageMismatchError,OBJECT_OVERLAY_PACKAGE_MISMATCH/422). A draft save is judged against the owner at the time of that save. So package Q's draft of an object that no row held yet was stored, correctly. Then package P saved a runtime-authored row of that object, and P owned it. Promoting Q's draft after that wrote Q's active row beside P's and answered success. The promotion never asked the save door's question. The registry write-through then refused the row intoapplyObjectRegistryMutation'sconsole.warnand kept serving P.promoteDraftForPublish(packages/metadata-protocol/src/protocol.ts) is the one promotion step that both doors call: the per-item publish (publishMetaItem,POST /api/v1/meta/:type/:name/publish) andpublishPackageDrafts(POST /api/v1/packages/:id/publish-drafts). It now asksclassifyObjectContribution(name, promoteInto)after the gated draft read, and throwsoverlayPackageMismatchErroron amismatchverdict, beforerepo.promoteDraftwrites. It is the save door's judge and envelope, unchanged: one refusal, no second dialect, no new code, no new message.promoteInto, the package the active row is written under. That is the stated binding, or, for a package-unbound draft adopted into the item's package (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), that package. The save door judges the address it resolved in the same way.!this.isSealedManagedItem(singularType, request.name). An object a code package ships is sealed (ADR-0131 D6). For that row the save door answers its package door first (refusePackagedBaseOverride,NOT_OVERRIDABLE/403, ahead of its D9.9 call). The promotion reaches the same refusal at the store (repo.promoteDraft→assertAllowed). Without the guard,classifyObjectContribution's packagedmismatcharm answered422with a prescription ("bind to the owner") that the sealed rule refuses. This was contract-review finding ①4 (6195338637), and the seat ruled route (A) (6195354019).namerefusal, the permission-set owner binding, the default record page and the elevated flow. So a batch publish aborts on it as on them (ADR-0067 D2), and a publish with no draft at the address still answers404 NO_DRAFT.sys_metadatasentinel). It 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 (its claim 6195283743 and dispatch 6195318252 pin[P, Q, package-less]), which will changeclassifyObjectContribution's sentinel arm; this PR does not touch that arm. 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 remains open; per 6187770366 the seat reopens this card if 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 does not pin member 2.Measured before the fix (real composition, base
57994c311f)Real
ObjectQLover a realSqlDriver(better-sqlite3), the realPackageServicePlugin,ObjectStackProtocolImplementationandHttpDispatcher.handlePackages. Packagescom.probe.pandcom.probe.qare installed. Q's draft ofprobe_ticketis saved (saveMetaItem, mode draft), then P's active row is saved:publishMetaItem({ type: 'object', name, packageId: Q })success: true, "Published draft — type=object, name=probe_ticket [seq=3]"active:com.probe.p,active:com.probe.qcom.probe.p/ P'sPOST /api/v1/packages/com.probe.q/publish-drafts200,outcome: 'published',failed: []active:com.probe.p,active:com.probe.qcom.probe.p/ P'sThe batch door's answer to a refusal inside a promotion was read from the code at the base. Any throw from
promoteDraftForPublishrolls the batch back and answers200withsuccess: falseandoutcome: 'refused'. The causal draft is infailed[]with its owncode(clientFacingFailureCode;OBJECT_OVERLAY_PACKAGE_MISMATCHis in the error-code ledger) and its message (a 4xx is quoted). Every other draft is listedBATCH_ABORTED. This PR keeps that declared shape (PublishPackageDraftsResponseSchema) and adds nothing to it.Measured after the fix
New pin
packages/runtime/src/package-promote-runtime-owner-refusal.integration.test.ts, 8 cases, on the same composition:publishMetaItemnaming Q: throws{ code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 }, withpackageIdQ andownerPackageIdP. Its message equals the save door's refusal of the same row; the test compares the two answers and pins no wording. Nothing is written: active rows[P], Q's draft still stored, and the registry serves P.POST /packages/Q/publish-drafts:200,success: false,outcome: 'refused',publishedCount: 0,failedCount: 1.failed[0]is the object withcode: 'OBJECT_OVERLAY_PACKAGE_MISMATCH'and the save door's message. Nothing is written.registerAppregisters it: contributors only,_packageId: O,_provenance: 'package'), and Q's draft is a row stored before O shipped it, since the save door refuses that draft today. Nothing is written either way: no active row, Q's draft still stored, and the registry still serves O.publishMetaItemnaming Q answers{ code: 'NOT_OVERRIDABLE', status: 403 }. The save door answers the same for the same row.publish-draftsanswersoutcome: 'refused'withfailed[0].code: 'NOT_OVERRIDABLE'.OBJECT_OVERLAY_PACKAGE_MISMATCH.publishMetaItemand bypublish-drafts(active[Q], registry Q).publish-draftsover P's row.publishMetaItemnaming no package still promotes a package-unbound draft INTO P (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's adoption).Red before:
57994c311f, the runtime-owner refusal cases read 2 failed and 4 passed.3f0acc173(the first round's check, no guard), the two code-packaged CONTROLs failed and 6 passed. Each answeredOBJECT_OVERLAY_PACKAGE_MISMATCH(per-item:status: 422) whereNOT_OVERRIDABLE/403was pinned. Finding ①4 is reproduced.Green after: 8 passed at
ea873b091.Ablations, both through
scripts/ablation-replace.mjs(WRAP mode, with a trap restore). Each rebuilt@objectstack/metadata-protocol, andscripts/ablation-dist-preflight.mjsproved the marker indist/index.jsanddist/index.cjs. Each restore proved the blob equal to HEAD,git diff HEADempty, the package rebuilt,--absent(marker in none of 24 built files) and the tree clean.5c2385ebd:classifyObjectContribution(...)replaced by a planted non-mismatchverdictfa49911fd8→034c466abbTests 2 failed | 4 passed (6), the two refusal casesea873b091:&& !this.isSealedManagedItem(singularType, request.name)replaced by an always-true term700377269e→f2e19c39abTests 2 failed | 6 passed (8), the two code-packaged CONTROLsTests
Every command ran under
scripts/pm/os-verify-lock.sh, and each verdict is itsVERDICT command-exitline (or, in a;-sequenced hold, the part's own echoed exit).This round, at
ea873b091. That head mergesorigin/mainatcbe771270, which includes #23646'sreassignOrphanedMetadatachange toprotocol.ts. Every run was targeted, a declared narrowing; the whole-package farms are CI's this round.--project local)publishMetaItem,publishPackageDrafts,promoteDraftForPublish,OBJECT_OVERLAY_PACKAGE_MISMATCH,classifyObjectContribution,overlayPackageMismatch)protocol-runtime-owner-overlay-refusal,protocol-object-overlay-layer,registry-object-overlay-layer,protocol-publish-package-drafts,publish-package-drafts-response-conformance,publish-meta-response-conformance,protocol-publish-canonical-fold,protocol-publish-rollback,protocol-lock-enforcement,protocol-delete-object-registry-heal,protocol-duplicate-package-unprefixed-object,default-record-page-save-door,protocol-commit-history,build-probes)http-dispatcher,domains/meta-published-runtime-publish,domains/packages-publish-drafts-response-conformance, the threepackage-uninstall-*integration files)pnpm --filter @objectstack/metadata-protocol --filter @objectstack/runtime run typecheckcheck:test-typecheck: "27 file(s) / 190 error(s) / 68 pinned signature(s) held"First round, at
5298b986e(mergingorigin/mainat7e6e4e1da):Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackatea873b091, the final head, against merge basecbe771270: 65 commands.turbo run buildcame first (75 tasks, exit 0).check:dual-build-cjs-loads: "110 published require entry point(s) across 68 package(s) load".check:type-check-debt: "1 ledger entr(ies) re-measured … none above its recorded number".check-adr-0087-registration:not-required (no-migration-prescription).--ran: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN", a derived zero, since every entry recorded its exit code.origin/mainhas since moved one commit, to3bc407d30(#23687, objectql's metadata-service copy). It touches none of this PR's three files and none of the derived inputs, so it was not merged.CI owns everything the derivation does not place: the artifact-roster and wide-population families, the type-check lanes, Test Core, Build Core, dogfood and the whole-package suites.
Acceptance notes
publish-draftsanswers200withoutcome: 'refused'and the code and message infailed[], the same shape as every other refusal inside a promotion (ADR-0067 D2,PublishPackageDraftsResponseSchema). The card's "same code and prescription" holds infailed[0].codeandfailed[0].error.NOT_OVERRIDABLE/403from the store, as before this PR and as the save door answers. Both doors pin it. The changeset's "Unchanged" list names it.applyObjectRegistryMutation's note is now true of one more caller, and was left as is. Itsmismatchbranch says it is unreachable fromsaveMetaItemand lists "rollback, publish promotion" as the other callers. Publish promotion now refuses at its producer too, and a sealed object's promotion never reaches the write-through. The note stays true and sits outside this card's surface.rollbackMetaItemrestores an existing row under that row's own binding, so it mints no new binding.Body refreshed 2026-10-15T16:52Z by
domain:engineseat 1 from the dev's round-2 text (report 6197439689).