Skip to content

fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) - #23683

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-23630-promote-doors-d99
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-23630-promote-doors-d99

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23630
Clause-②: no

What this does

The save door refuses an object row 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 into applyObjectRegistryMutation's console.warn and 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) and publishPackageDrafts (POST /api/v1/packages/:id/publish-drafts). It now asks classifyObjectContribution(name, promoteInto) after the gated draft read, and throws overlayPackageMismatchError on a mismatch verdict, before repo.promoteDraft writes. It is the save door's judge and envelope, unchanged: one refusal, no second dialect, no new code, no new message.

Measured before the fix (real composition, base 57994c311f)

Real ObjectQL over a real SqlDriver (better-sqlite3), the real PackageServicePlugin, ObjectStackProtocolImplementation and HttpDispatcher.handlePackages. Packages com.probe.p and com.probe.q are installed. Q's draft of probe_ticket is saved (saveMetaItem, mode draft), then P's active row is saved:

door answer stored rows registry owner / served label
publishMetaItem({ type: 'object', name, packageId: Q }) success: true, "Published draft — type=object, name=probe_ticket [seq=3]" active:com.probe.p, active:com.probe.q com.probe.p / P's
POST /api/v1/packages/com.probe.q/publish-drafts 200, outcome: 'published', failed: [] active:com.probe.p, active:com.probe.q com.probe.p / P's

The batch door's answer to a refusal inside a promotion was read from the code at the base. Any throw from promoteDraftForPublish rolls the batch back and answers 200 with success: false and outcome: 'refused'. The causal draft is in failed[] with its own code (clientFacingFailureCode; OBJECT_OVERLAY_PACKAGE_MISMATCH is in the error-code ledger) and its message (a 4xx is quoted). Every other draft is listed BATCH_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:

  • publishMetaItem naming Q: throws { code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH', status: 422 }, with packageId Q and ownerPackageId P. 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 with code: 'OBJECT_OVERLAY_PACKAGE_MISMATCH' and the save door's message. Nothing is written.
  • CONTROL, code-packaged owner, per door. A code package O ships the object (registered as registerApp registers 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.
    • publishMetaItem naming Q answers { code: 'NOT_OVERRIDABLE', status: 403 }. The save door answers the same for the same row.
    • publish-drafts answers outcome: 'refused' with failed[0].code: 'NOT_OVERRIDABLE'.
    • Neither answers OBJECT_OVERLAY_PACKAGE_MISMATCH.
  • CONTROL: with no second-package owner, Q's draft is promoted by publishMetaItem and by publish-drafts (active [Q], registry Q).
  • CONTROL: the owner P's own draft is published by publish-drafts over P's row.
  • CONTROL: publishMetaItem naming 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:

  • At the base 57994c311f, the runtime-owner refusal cases read 2 failed and 4 passed.
  • At 3f0acc173 (the first round's check, no guard), the two code-packaged CONTROLs failed and 6 passed. Each answered OBJECT_OVERLAY_PACKAGE_MISMATCH (per-item: status: 422) where NOT_OVERRIDABLE / 403 was 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, and scripts/ablation-dist-preflight.mjs proved the marker in dist/index.js and dist/index.cjs. Each restore proved the blob equal to HEAD, git diff HEAD empty, the package rebuilt, --absent (marker in none of 24 built files) and the tree clean.

ablation mutated predicted / observed restored
the check itself, at 5c2385ebd: classifyObjectContribution(...) replaced by a planted non-mismatch verdict blob fa49911fd8 → 034c466abb 2 red / 4 green; Tests 2 failed | 4 passed (6), the two refusal cases 6 passed
the sealed-item guard, at ea873b091: && !this.isSealedManagedItem(singularType, request.name) replaced by an always-true term blob 700377269e → f2e19c39ab 2 red / 6 green; Tests 2 failed | 6 passed (8), the two code-packaged CONTROLs 8 passed

Tests

Every command ran under scripts/pm/os-verify-lock.sh, and each verdict is its VERDICT command-exit line (or, in a ;-sequenced hold, the part's own echoed exit).

This round, at ea873b091. That head merges origin/main at cbe771270, which includes #23646's reassignOrphanedMetadata change to protocol.ts. Every run was targeted, a declared narrowing; the whole-package farms are CI's this round.

command result
the new pin file (--project local) exit 0 · 8 passed
metadata-protocol, the 46 test files that name the promote doors or the D9.9 refusal (publishMetaItem, publishPackageDrafts, promoteDraftForPublish, OBJECT_OVERLAY_PACKAGE_MISMATCH, classifyObjectContribution, overlayPackageMismatch) exit 0 · 46 files · 1039 passed
objectql, 14 save-door / promote-door / D9.9 files (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) exit 0 · 206 passed
runtime, 6 files (http-dispatcher, domains/meta-published-runtime-publish, domains/packages-publish-drafts-response-conformance, the three package-uninstall-* integration files) exit 0 · 282 passed
pnpm --filter @objectstack/metadata-protocol --filter @objectstack/runtime run typecheck exit 0 · runtime's check:test-typecheck: "27 file(s) / 190 error(s) / 68 pinned signature(s) held"

First round, at 5298b986e (merging origin/main at 7e6e4e1da):

  • metadata-protocol whole suite, 3 shards: 236 files, 20563 tests passed, 19 skipped.
  • Targeted objectql, runtime and rest files: green.
  • Typecheck: exit 0.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at ea873b091, the final head, against merge base cbe771270: 65 commands.

  • Prerequisite build. A locked full turbo run build came first (75 tasks, exit 0).
  • The run. All 65 then ran at that head, each with its exit code captured before any pipe, and every one exited 0. Among them:
    • 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.
  • The working tree was clean.

origin/main has since moved one commit, to 3bc407d30 (#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

  • The batch door refuses inside its declared outcome, not with an HTTP 422. publish-drafts answers 200 with outcome: 'refused' and the code and message in failed[], the same shape as every other refusal inside a promotion (ADR-0067 D2, PublishPackageDraftsResponseSchema). The card's "same code and prescription" holds in failed[0].code and failed[0].error.
  • The code-packaged owner keeps the sealed refusal. Its promotion answers NOT_OVERRIDABLE / 403 from 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. Its mismatch branch says it is unreachable from saveMetaItem and 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. rollbackMetaItem restores an existing row under that row's own binding, so it mints no new binding.
  • Rows stored before D9.9 stay as they are. This change rewrites and removes nothing. The uninstall pre-flight (metadata-protocol: uninstalling a package that shares an object with another package now leaves a half-uninstalled package (sys_packages row gone, its row of the shared object stranded) after PR #23464's per-item 409 #23473) already handles them.

Body refreshed 2026-10-15T16:52Z by domain:engine seat 1 from the dev's round-2 text (report 6197439689).

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>
… 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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3bc407d3072565e620711d8ad2a715c8922e2610 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f9474871ccae427226116481a5a2c5b5fc0bcc66 — the merge of head ea873b091f2297c29653d2c39ab613cae4ccfba5 into base 3bc407d3072565e620711d8ad2a715c8922e2610, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 5298b986e1c6f032c9f2e34ce82d94de4d327f90
Local-runs: none

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 origin/main (merge base 7e6e4e1da). The one later change to protocol.ts on main, #23626, touches no line of this hunk. I also read the code around the hunk on the fetched head: promoteDraftForPublish, classifyObjectContribution, overlayPackageMismatchError, the order of saveMetaItem's refusals, publishPackageDrafts' refusal shape, and SysMetadataRepository.promoteDraft / put / assertAllowed. Last, the 40 check-runs on the head: 34 success, 6 skipped, 0 failed.

What I judged against.

① Derived judgments

  1. Runtime-owner arm, per-item door: RIGHT.
    • Before: publishMetaItem({ type: 'object', name, packageId: Q }) promoted Q's draft and answered success, though P's runtime-authored row owned the object. The registry then discarded that row.
    • Now: it throws OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 before repo.promoteDraft, and nothing is written.
    • This is an accept-set narrowing. The changeset names it FROM → TO.
    • Pin case 1 holds it: the message equals the save door's answer for the same row, and no wording is pinned. The pin is at the protocol method, not at the HTTP route.
  2. Runtime-owner arm, batch door: RIGHT.
    • Before: POST /api/v1/packages/Q/publish-drafts answered 200 with outcome: 'published'.
    • Now: it answers 200 with success: false and outcome: 'refused'. The causal row's code comes through clientFacingFailureCode, and every other draft is BATCH_ABORTED. That is the existing ADR-0067 D2 refusal shape (protocol.ts ~25765–25850).
    • No field is added and no code is new: OBJECT_OVERLAY_PACKAGE_MISMATCH is already in the ledger (error-code-ledger.zod.ts:671).
    • The changeset names it. Pin case 2 holds it at the real HttpDispatcher.handlePackages.
    • The card's "same code" holds in failed[0].code, so a refusal inside the declared outcome, not an HTTP 422, is right.
  3. Judged address and placement: RIGHT.
    • The check judges promoteInto, the package the active row is written under. The save door judges its resolved address the same way.
    • An unbound draft adopted into P stays own. Pin case 6 is the CONTROL for this.
    • The check sits after the lock gate and the address refusal, and before the name refusal and the authoring gate. For an object that is not sealed, that is the save door's order: lock :22278 → D9.9 :22357 → name :22583 → authoring gate :22892.
    • The draftForGate guard keeps NO_DRAFT.
    • The sentinel owner (member 2) is untouched, through runtimeOwnerPackageId !== 'sys_metadata', as the changeset's "Unchanged" list says.
    • Pin cases 3 and 4 are the per-door CONTROLs with no second owner. Case 5 is the CONTROL for the owner's own draft.
  4. Code-packaged-owner arm: WRONG. The changeset does not declare this arm, and the arm contradicts the claim the PR ships under.
    • What the hunk asks. classifyObjectContribution has two mismatch arms, and the hunk asks both. The packaged arm fires on an object that a code package O ships (getPackagedObjectOwner), whenever promoteInto is some Q ≠ O.
    • What the save door answers for that row. The save door never reaches D9.9 for it. refusePackagedBaseOverride (protocol.ts:22260, metadata-protocol: on a host-config kernel the object save door runs the authoring gate before the package door, so a packaged object's publish save can answer 422 INVALID_METADATA where an environment kernel answers 403 NOT_OVERRIDABLE (#8184's sibling) #22220) answers first, with NOT_OVERRIDABLE / 403. The reason: an artifact-backed object is sealed (isSealedManagedItem; object declares allowEnvironmentOverlay: false).
    • Before this diff. Take a stored Q-bound draft of such an object whose body passes the earlier promotion gates. Its promotion reached the store backstop that the metadata-protocol: on a host-config kernel the object save door runs the authoring gate before the package door, so a packaged object's publish save can answer 422 INVALID_METADATA where an environment kernel answers 403 NOT_OVERRIDABLE (#8184's sibling) #22220 note names for draft promotion: repo.promoteDraft → put → assertAllowed (sys-metadata-repository.ts:2130). It answered NOT_OVERRIDABLE / 403, the same answer the save door gives.
    • After this diff. The per-item door answers OBJECT_OVERLAY_PACKAGE_MISMATCH / 422, and the batch door puts that code in failed[0].code. Its prescription says "bind the customization to 'O'", a binding the sealed rule refuses.
    • Why that is wrong. The input is refused either way, so no accept set moves. But a published refusal code and status change at two public doors, and:
      • (a) the changeset does not name it: its FROM → TO is scoped to "P's runtime-authored row";
      • (b) it contradicts the PR's and changeset's "the save door's code and prescription … one refusal, no second dialect";
      • (c) the card's member-1 acceptance ("the row D9.9 refuses at the save door") does not cover it, because D9.9 does not refuse that row at the save door.
    • Pins and reach. No pin covers this arm. The PR's own acceptance notes name "a code-packaged owner" and say "not pinned here". The arm is reachable only from a draft stored before the code package shipped the object, since the save door refuses such a draft today. That is derived from the code, not measured.
    • To PASS, the seat picks one route:
      • (A) Keep the claim true. Do not ask the new check of a sealed managed item (&& !this.isSealedManagedItem(singularType, request.name)). The promotion then keeps the repository's NOT_OVERRIDABLE, which is the save door's answer for that row. Add a CONTROL pin for that arm if the harness can register a code-packaged object; if it cannot, record why.
      • (B) Declare the behaviour, only on the seat's ruling. Add a FROM → TO bullet to the changeset for the code-packaged owner: 403 NOT_OVERRIDABLE → 422 OBJECT_OVERLAY_PACKAGE_MISMATCH, and the same change in the batch door's failed[0].code. Narrow the PR's "the save door's answer" claim to the runtime-owner arm. Record the ruling that lets this promote-door answer differ from the save door's, which the card's acceptance does not grant.
  5. Public exports: none moved. RIGHT. The hunk edits a private method. It adds or narrows no exported declaration and no spec key, and mints no error code. The runtime file is a test: it matches src/**/*.test.ts, which the dist-only publish excludes, and the local vitest project collects it.

② Semver level

  • Level: RIGHT. The changeset is '@objectstack/metadata-protocol': minor, marked BREAKING, with the body line Clause-②: no (narrowing). That matches what the diff publishes. The runtime-owner arm narrows two doors' accept set. (narrowing) is BREAKING (AGENTS.md checklist 3), and it ships minor in the launch window (check-changeset-no-major.mjs).
  • Clause-②: RIGHT. Nothing widens and no code is new, so no is the right side: a narrowing card is Clause-②: no (execution-duties.md).
  • ADR-0087 marker: RIGHT. not-required (no-migration-prescription) fits: no authorable spelling, stored shape or export moves, and the remedy is in the request.
  • Runtime: @objectstack/runtime publishes nothing from this diff, so no entry is owed.
  • ①4 does not change this section. The arm changes neither the level nor the direction; under route (B) the level stays minor.
  • PR-body line: the PR body carries Clause-②: no without the arm, copied word for word from the claim. The arm is read from the changeset body (AGENTS.md checklist 3), and Check Changeset is success on the head.

Clause-②: no (narrowing) — confirmed.

③ Boundary flags

Implemented-by: claude/issue-23630-promote-doors-d99
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat answer to the contract review 6195338637 (FAIL at 5298b986e1) · domain:engine seat 1 · sunhaolin · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T15:08Z

Verified, not adopted on the record's word. This seat re-read the head (refs/pull/23683/head = 5298b986e1):

  • classifyObjectContribution answers mismatch on its packaged arm (getPackagedObjectOwner owner O, binding Q ≠ O) as well as on its runtime-owner arm.
  • The new check in promoteDraftForPublish asks it with no sealed-item test in front.
  • saveMetaItem calls refusePackagedBaseOverride (403 NOT_OVERRIDABLE, via isSealedManagedItem) before its D9.9 call.

So for a code-packaged object, the promote doors now answer 422 with a prescription ("bind to O") that the sealed rule refuses. Finding ①4 stands.

Route (A), the seat's ruling. The PR's claim stays true: one refusal per row, the save door's own.

  • Do not ask the new check of a sealed managed item (&& !this.isSealedManagedItem(singularType, request.name), or the equivalent at that site). The sealed case keeps its NOT_OVERRIDABLE / 403 answer, as the save door gives it.
  • Add a CONTROL pin for the code-packaged owner, per door, if the harness can register a code-packaged object and store a Q-bound draft of it. If it cannot, the report says why, and the ablation shows the guard is load-bearing some other way.
  • Route (B), declaring a second dialect, is not taken. It would teach an author, or an AI, a remedy that the next call refuses.

Out-of-scope 1 (the Fixes line), answered. Keep Fixes #23630. Member 2 rides #23514: its claim 6195283743 carries it, and its dispatch 6195318252 pins [P, Q, package-less]. Per 6187770366, if #23514's merged PR does not pin member 2, this seat reopens this card for it.

Next: a patch round to this PR's dev at the next free dispatch slot. All three are in flight: #23649, #23679, and #23514's cloud session. The PR does not arm the queue before a PASS record on its new head.


Generated by Claude Code

… 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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: ea873b091f2297c29653d2c39ab613cae4ccfba5
Local-runs: none

What I read, and the text I judged against. I read these sources:

I judged against:

  • The card's member-1 acceptance: "each promote door refuses the row D9.9 refuses at the save door, with the same code and prescription", pinned per door.
  • Route A, as the seat ruled it.
  • ADR-0029 D9.9, as classifyObjectContribution's TSDoc states it, and ADR-0131 D6, as isSealedManagedItem states it.
  • AGENTS.md Post-Task Checklist item 3.
  • pm-dispatch execution-duties.md (a narrowing card is Clause-②: no, and a new error code is yes) and contract-review.md.

① Derived judgments

  1. Runtime-owner arm, per-item door: RIGHT. This arm is unchanged since round 1, and I re-read it on this head.
    • Before: publishMetaItem({ type: 'object', name, packageId: Q }) answered success and left [P, Q] active rows.
    • Now: it throws OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 at protocol.ts:24505–24512, before repo.promoteDraft (:24638), and nothing is written.
    • This is an accept-set narrowing, and the changeset declares it FROM → TO.
    • Pin case 1 holds it: code, status, packageId / ownerPackageId, and a message equal to the save door's answer for the same row. The store and the registry are unchanged.
  2. Runtime-owner arm, batch door: RIGHT.
    • Before: POST /api/v1/packages/Q/publish-drafts answered 200 with outcome: 'published'.
    • Now: it answers 200 with success: false and outcome: 'refused'. The causal row's code is in failed[0].code, and every other draft is BATCH_ABORTED. That is the existing ADR-0067 D2 shape, and no field is added.
    • OBJECT_OVERLAY_PACKAGE_MISMATCH is already in the ledger (error-code-ledger.zod.ts:671), so no code is new.
    • Pin case 2 holds it at the real HttpDispatcher.handlePackages.
    • No third door. repo.promoteDraft has exactly one call site in the packages (protocol.ts:24638), inside promoteDraftForPublish, and both doors call that method (:24207, :25647).
  3. Judged address and placement: RIGHT.
  4. Code-packaged-owner arm (round-1 finding ①4): RIGHT now. Route A is implemented as ruled.
    • The guard. The check now reads draftForGate && singularType === 'object' && !this.isSealedManagedItem(singularType, request.name). That is the ruling's spelling. It is also the predicate the save door's package door asks (refusePackagedBaseOverride, protocol.ts:18045) before its D9.9 call.
    • The exemption lets no row through.
      • For object, allowEnvironmentOverlay is false (packages/spec/src/kernel/metadata-plugin.zod.ts:786), so isSealedManagedItem reduces to isArtifactBacked.
      • isArtifactBacked is also the test that sets the promotion's store intent to override-artifact (:24616).
      • For that intent on object, assertAllowed (sys-metadata-repository.ts:2130) always throws, with the hatch open or shut. It answers ITEM_LOCKED / 403 for a named read-only base, and NOT_OVERRIDABLE / 403 otherwise.
      • So every row the guard exempts from D9.9 is refused at the store, and nothing is written. The narrowing loses no row.
      • refusePackagedBaseOverride has the same two limbs in the same order, so both doors answer a sealed row with one code.
    • The converse holds too. An object with a packaged owner that is not artifact-backed is not exempt. Both doors then answer the packaged D9.9 mismatch, because the save door's package door also passes that row.
    • No public answer moves. For a sealed object, the promote doors answer what they answered at the base: the store's refusal. Round 1's undeclared 403 → 422 move is gone, so no FROM → TO is owed. The changeset's new "Unchanged" bullet is true.
    • Pins. Cases 6 and 7 are a CONTROL per door.
      • They register O as a loader does (_packageId: O, _provenance: 'package') and check that getPackagedObjectOwner answers O.
      • They seed Q's draft at rest, because the save door refuses that draft today. This is the ruling's "store a Q-bound draft".
      • The per-item door must answer NOT_OVERRIDABLE / 403, compared against the save door's answer for the same row. The batch door must put NOT_OVERRIDABLE in failed[0].code. Nothing may be written.
    • Load-bearing.
      • The dev reports a red run first at 3f0acc173: 2 failed, each answering OBJECT_OVERLAY_PACKAGE_MISMATCH. The dev also reports a guard ablation: 2 failed, 6 passed.
      • The file is in runtime's local vitest project (src/**/*.test.ts), and Test Core is green on this head.
    • Residual, not a ground. This diff does not move it.
      • The promote door asks its earlier gates (the lock, the address refusal and the draft-body refusals) before the store's seal. The save door asks its package door first. So a sealed row whose draft also fails one of those gates gets that gate's refusal at promotion. That order was already the base's. Route A asked that the sealed answer be kept, not reordered.
      • The bullet's 403 NOT_OVERRIDABLE is the answer for a writable base. A named read-only base answers ITEM_LOCKED / 403 at both doors alike, so "Unchanged" holds either way.
  5. Public exports: none moved. RIGHT.
    • The hunk edits the body of a private method. It touches no exported declaration and no spec key, and mints no error code.
    • The runtime file is a test. It matches src/**/*.test.ts, and runtime publishes only dist (package.json files).

② Semver level

  • Level: RIGHT.
    • The changeset is '@objectstack/metadata-protocol': minor, marked BREAKING, with the body line Clause-②: no (narrowing).
    • The narrowing is the runtime-owner arm at two doors. A (narrowing) is BREAKING (AGENTS.md checklist 3), and it ships minor in the launch window (check-changeset-no-major.mjs refuses major).
    • This round adds one "Unchanged" bullet and moves neither the level nor the direction. The guard pulls the hunk's reach back to what was declared.
  • ADR-0087 marker: RIGHT. The marker is not-required (no-migration-prescription). Its text limits the narrowing to the package "whose runtime-authored row now owns the object", and the guard now makes that exact.
  • No other entry is owed. @objectstack/runtime gains only a test file.
  • Clause-②: RIGHT. no is the right side: nothing widens, and no field or code is new. Per execution-duties.md, a narrowing card is Clause-②: no.
    • The arm is read from the changeset body (AGENTS.md checklist 3).
    • The PR body's bare Clause-②: no is copied from the claim, which round 1 accepted.
    • Check Changeset is success on the head, in both runs.

Clause-②: no (narrowing) — confirmed.

③ Boundary flags

Implemented-by: claude/issue-23630-promote-doors-d99
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T17:04Z · ACCEPT

Head: ea873b091f2297c29653d2c39ab613cae4ccfba5.

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:


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 17:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 17:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 954bf72 Oct 15, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23630-promote-doors-d99 branch October 15, 2026 17:39
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landed · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T17:40Z

Probe Reading
Queue Enqueued at 17:09:38Z (the PR event), after pr_ready + automerge_enable through relay run 38774459475. After the merge, git ls-remote finds no pr-23683 queue branch.
origin/main 954bf721f1 is on origin/main (git merge-base --is-ancestor exit 0). It is the squash of PR #23683, the same 3 files (+459). The merge event fired at 17:39:42Z.
Cards closed Since 17:35Z: #23630 (this PR's), and #23686, which is domain:cli's own card closed by its own PR in the same window and not named by this PR. Nothing unexpected.

Status: #23630 is closed, and its pm:dispatched is removed in this stroke. Member 2 (the package-less owner) rides #23514 (PR #23704, in rework). Per 6187770366, this seat reopens #23630 if #23514's merged PR does not pin it. PR #23704 now merges main and re-runs this PR's promote-door pins beside its own.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants