Skip to content

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

Merged
objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/issue-23514-sentinel-owner-save
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 23 commits into
mainfrom
claude/issue-23514-sentinel-owner-save

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 the sys_metadata sentinel as it judges one bound to a real package. A save naming any package Q of an object whose registry owner is its package-less definition is a mismatch, so saveMetaItem's existing ADR-0029 D9.9 call refuses it before any write, with the existing code OBJECT_OVERLAY_PACKAGE_MISMATCH / 422 and no new error code.

overlayPackageMismatchError hands the sentinel owner to a new private unboundOwnerPackageRefusal, because D9.9's prescription ("bind it to the owner's package") would read "bind it to sys_metadata", and the package-less state is no authoring destination (ADR-0070 TL;DR 1). The refusal names the measured way out, prescription first:

Move object 'NAME' into package 'Q' first: delete its package-less definition without dropStorage, which keeps its table and records (REST: DELETE /api/v1/meta/object/NAME with no package parameter; in process: deleteMetaItem without packageId), then send this save again. Nothing was written: …

The error carries packageId: Q and ownerPackageId: 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 with main. What changed since that sha: two main merges (no conflict: #23626's sealed-write refusal and #23654's draft addressing sit above the D9.9 call and were left as main has them); two new boot-hydrated pins; the pin's read options typed (check:query-options-erasure); the code header's note on adopt-orphans rewritten, since PR #23688 (merged after the first merge) makes it hand an adopted object's owner to Q in 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 naming Q) 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 with MetadataPlugin (NodeMetadataManager, watch: false, the composition whose boot-hydrated delete failed before #23659): seed package-less + 2 records, reboot, save naming Q refused 422, delete (no package, no dropStorage) → the registry owner, getObject, getMetaItem and the data plane (OBJECT_NOT_FOUND / 404) all say gone at once, table keeps 2 records; save naming Q → owner Q, data plane and metadata read serve Q's definition, 2 records; insert a 3rd; reboot → owner Q, 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 and OS_HOME so a restart is a real boot; seeded admin session):

  1. PUT /api/v1/meta/object/qa_probe_gadget (no package) → 200; two POST /api/v1/data/qa_probe_gadget → 201, 201.
  2. PUT …?package=com.qa.q → 422 OBJECT_OVERLAY_PACKAGE_MISMATCH, the message above; GET meta still "Gadget unbound", data 200 2 records.
  3. Restart (boot-hydrated). GET meta "Gadget unbound", data 2 records. PUT …?package=com.qa.q → 422 again.
  4. DELETE /api/v1/meta/object/qa_probe_gadget → 200 "Deleted object 'qa_probe_gadget' — it no longer exists." Then meta GET → 404, data GET → 404 OBJECT_NOT_FOUND (gone at once).
  5. PUT …?package=com.qa.q → 200 "Saved object … package=com.qa.q". Meta "Gadget Q", data 200 2 records (kept). POST rec-3 → 201, 3 records.
  6. Restart. Boot log: 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 on main during 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 is Q, so the save classifies own and is not refused. The prescription does not name adopt-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):

Negative control (one-time ablation, not a permanent case)

objectql reads metadata-protocol through dist/ (a KNOWN_UNALIASED_TEST_IMPORTS pair), so each leg rebuilt and ran the preflight. Fix blob at HEAD 1d7f77e50f53.

  • First attempt was a no-op and is void: an insertion anchor kept its count, ablation-replace refused ("the anchor count moved 1 -> 1") and restored; nothing was read from it.
  • Mutant: re-added the sentinel carve-out (&& runtimeOwnerPackageId !== 'sys_metadata', spelled with a marker) via scripts/ablation-replace.mjs: anchor 1 -> 0, blob 1d7f77e50f53 -> 3dd4bee907ce; rebuild exit 0; ablation-dist-preflight: marker present in dist/index.js and dist/index.cjs. Pin file: 5 failed | 1 passed (the CONTROL stays green by design). Expected red, observed red.
  • Restore: git checkout HEAD --, blob 1d7f77e50f53 == HEAD, git diff HEAD empty; 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, each VERDICT command-exit 0:

  • @objectstack/metadata-protocol full suite, 3 shards (--maxWorkers=2): 80 + 78 (2 skipped) + 78 (1 skipped) files passed; 1340 + 17832 + 1388 = 20560 tests passed, 19 skipped.
  • @objectstack/objectql full suite (--project local): Test Files 426 passed (426), Tests 8140 passed (8140).
  • typecheck for metadata-protocol and objectql: exit 0; check:test-typecheck OK, debt unchanged (39 files / 233 errors / 64 pinned signatures).
  • Neighbour pins on the merged head: 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-ownership and this pin: 10 files, 82 passed; metadata-protocol's protocol.object-registry-write-through-spelling: 12 passed.

Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 62f6c360f derived 67 commands (3 paths vs merge base 3bc407d30); 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 after git fetch --shallow-since=2026-07-10 origin main. check:query-options-erasure was red at d22a2f79 (test surface 236 → 238 from the pin's as any read options) and is fixed in 0afedaba2. 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

  • A DRAFT save naming Q is refused with its own order: delete the package-less definition without dropStorage, then a publish-mode save naming Q (it serves the object at once), then draft. Measured: a draft right after the delete leaves the object 404 until published.
  • Rows stored before this refusal, [package-less, Q]: a save naming no package is addressed to Q's row and told to DELETE ?package=Q first, then send it again (it then edits the package-less definition); a save naming Q is told the move: DELETE ?package=Q, then the package-less DELETE without dropStorage, then a publish-mode save naming Q. The no-package DELETE over those rows removes Q's row (measured), so no prescription relies on it. The boot's warning for those rows carries the same move, ending: The row bound to Q is not registered.
  • The promote doors (fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) #23683, now on main): a Q draft over a package-less owner is refused with: the same deletes, then POST …/publish?package=Q (the publish names the draft's package since Round 5).
  • Pins: 10 cases (4 new); ablation of the state read: 4 failed / 6 passed, restore 10 passed. HTTP walk on dev:crm across a restart for the draft door and the legacy rows. Gates at e0d97ad: 67 x exit 0, --ran derived zero.

Round 3 (contract review 6199542688): every package's stored row is named by its own delete

  • The refusal's state carries every package bound to an active row stored beside the package-less one (packagelessAddress(…).candidates), not only the named Q. Every text (save in publish and draft mode, the promote doors, the boot and the write-through) names DELETE …?package=PKG for each such row, then the 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).
  • Measured before the edit: over [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 answers 409 RESOURCE_CONFLICT and deletes nothing. The named walk converges across a boot: owner Q, records kept, nothing dropped.
  • Every step fits the 500-character REST bound: at most 452 characters with two packages and long names.
  • Pins: 13 cases (3 new: [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

Round 5 (contract review 6203512210): the promote door's publish step names the package

Acceptance notes

  • fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) #23683 landed first (954bf72); this branch merges it, and its promote doors give the promote text above for a package-less owner. Its pin (package-promote-runtime-owner-refusal.integration) runs green beside these: 8 passed.
  • A save naming no package over an object Q owns resolves to Q's row (packagelessAddress, by design) and is unchanged here.
  • applyObjectRegistryMutation is 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.
  • Rows stored before this change ([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:engine seat 1 from the dev's round-6 report 6204057324 (its three listed edits), over the earlier refreshes.


Generated by Claude Code

…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>
…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>
…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>
@github-actions github-actions Bot added size/l 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

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 18 documentable anchor(s).

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 9bb93706fbdf57582329745b64c2f40c6a084572.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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 9bb93706fbdf57582329745b64c2f40c6a084572 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 2c92a8ebb5cebdc362e978f10ef5815bf45fb44b — the merge of head 9a259bd7d21e122543a6c178d8f4fbccd45aedb7 into base 9bb93706fbdf57582329745b64c2f40c6a084572, 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 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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 9bb93706fbdf57582329745b64c2f40c6a084572 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 62f6c360ff8ebd8f295d886c3d668cf113434dc0
Local-runs: none

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 origin/main...62f6c360f (merge base 3bc407d30, +509/−5). In protocol.ts at head I read the cited code: the three callers of classifyObjectContribution, packagelessAddress, the address block and the D9.9 block of saveMetaItem, and its publish-only write-through. I also read the REST save route. The 39 check-runs on the head, at the time of reading: 31 success, 6 skipped by path filter, 2 in progress (Test Core 2/6 and 3/6), 0 failed. Lint & Repo Gates, TypeScript Type Check, Check Changeset and Governed Surface Queue Guard are all success. Governing text: ADR-0029 D3 and D9.9; ADR-0070 TL;DR 1, D1 and D5; AGENTS.md Post-Task Checklist 3; clause2-line.mjs; check-changeset-no-major.mjs (launch-window grade, level axis); check-adr-0087-registration.mjs signal (4); the seat ruling 6188959051 (refuse with a real way out; option B not taken).

① Derived judgments

  1. RIGHT: the card's door. A publish save of an object whose registry owner is the sys_metadata sentinel, naming package Q, used to answer 200 and store a Q row that nothing served. It now answers 422 OBJECT_OVERLAY_PACKAGE_MISMATCH before any write. The diff drops one conjunct (runtimeOwnerPackageId !== 'sys_metadata') and adds an early return for the sentinel only, so the real-package arm's code, status and text are byte-unchanged. It is pinned on a real kernel across a boot: code, status, packageId, ownerPackageId and the first sentence; no row and no history entry stored; owner, data plane and metadata read agree. The ablation went 5 red with the CONTROL green, and the dev walked it over HTTP on SQLite across two restarts. This holds ADR-0029 D3 and ADR-0070 TL;DR 1.
  2. RIGHT: the way out, for an object saved in process and for one hydrated at boot (fallback metadata and MetadataPlugin). The two calls are a delete without dropStorage, then the save naming Q. After the delete the object is gone at once (owner, getObject, getMetaItem, data plane 404) and the table keeps its records. Q then becomes the owner and the served definition with the records kept, a boot agrees, and nothing is dropped. This is the dispatch's precondition (re-measure R4 on fix(metadata-protocol): deleting a runtime object hydrated at boot unregisters it at once; the metadata service's boot copy is not its code layer #23659's heal), and it is met.
  3. RIGHT: 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 member 2. Saves naming P and then Q are both refused, so the rows [P, Q, package-less] cannot be produced, across a boot. Pinned.
  4. RIGHT: the error code is reused. Its ledger meaning ("a row bound to a package that does not own the object") holds, since no package owns the object. There is no packages/spec edit. ownerPackageId: null is an in-process property that the REST envelope does not map, and no exported type changes.
  5. RIGHT: applyObjectRegistryMutation (the rollback and publish-promotion write-through). For a sentinel owner the throw moves from the registry's ownership conflict to D9.9's. Both land in the same best-effort warn, so nothing registers either way; only the warn text changes.
  6. WRONG: an unnamed narrowing, and a changeset sentence that is false. The case is legacy residue [package-less, Q]: the shape this card's defect wrote (R1, R2), still producible by the promote doors until PR fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) #23683. For it, packagelessAddress addresses a save naming NO package to Q's row, because one binding is present and the unbound row is no candidate. So the D9.9 call refuses that save 422 with "Move object 'NAME' into package 'Q' first", although the caller named no package. On main it answered 200 and the registry discarded it.
    • The changeset's Unchanged bullet ("A save naming no package over the package-less owner") is false for this population, and no pin covers it: the CONTROL seeds [null] only.
    • The prescription misdescribes its own first call here. A DELETE with no package parameter resolves through the same packagelessAddress to Q's row, not to "its package-less definition" (R6; kept "by design" in 6188959051).
    • Followed literally, it converges and nothing is dropped. But the 422 and the CHANGELOG describe a different act from the one that happens.
  7. WRONG: the remedy text is false for a door this change reaches, the draft door. The D9.9 call is not mode-gated, and the REST route forwards mode and package, so PUT /api/v1/meta/object/NAME?package=Q&mode=draft (Studio's designer door) is refused too. The refusal itself is right, because a Q draft could never go live. But the changeset's Remedy ("send the same PUT again: Q's row becomes the owner at once") is false for a draft. The write-through runs only for mode === 'publish', so after the prescribed DELETE the object answers OBJECT_NOT_FOUND to the data plane until that draft is published. The seat's premise was measured for publish only, and no pin or walk covers the draft door.
  8. RIGHT in verdict, with an unnamed change of reason: boot hydration of residue. When the package-less row hydrates first, Q's row is still counted in loadMetaFromDb errors (D9.9's boot half). Its reason is now this card's prescription, misdirected exactly as in 6. "A boot judges them as before" holds for the verdict only.

② Semver level

  • RIGHT: @objectstack/metadata-protocol: minor, with the BREAKING banner, the ! summary and Clause-②: no (narrowing) in the changeset. A narrowing is BREAKING, and it ships minor in the launch window. The objectql file is a test, and objectql publishes dist only, so no second entry is owed. FROM → TO and the one-line remedy are present, but judgments 6 and 7 make two of its sentences false.
  • RIGHT: ADR-0087 not-required (no-migration-prescription). No stored row, key or export moves, and the remedy is a sequence of requests. The gate printed [BREAKING+bang+clause-②-narrowing] not-required.
  • Clause-②: the value no is right, since nothing widens. The PR body names no arm, while the changeset carries (narrowing). The body is the one carrier Check Changeset's level axis reads, so the axis read not-declared and stood down instead of reading clean. AGENTS.md has the changeset carry the PR's own line, so the two should match. Owed: the body line becomes Clause-②: no (narrowing). The grade is not wrong.

③ Boundary flags

Owed for a PASS. It is narrow, and the card's door does not change:

  1. Changeset: name the residue arm, FROM 200-discarded TO 422 for a save naming no package over [package-less, Q]. Its first DELETE removes Q's never-served row. Correct the Unchanged bullet. Name the draft arm, and say the object is unserved from the delete until the draft is published.
  2. The refusal message for these two shapes: say what actually happens, or carve the residue case so the prescription stays true. Keep the prescription first, within the 500-character bound.
  3. Pins: the residue save naming no package (refused, code and status; the prescribed walk converges with nothing dropped), and the draft save naming Q (refused, code and status).
  4. PR body: Clause-②: no (narrowing), and "a boot judges them as before" names the changed reason.

Implemented-by: session_01TkXMn8bJqX6ZgMRwuZTxpf
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat answer to the contract review 6198087441 (FAIL at 62f6c360ff) · domain:engine seat 1 · sunhaolin · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T17:23Z

Verified, not adopted on the record's word. At this head, saveMetaItem's D9.9 call (classifyObjectContribution → unboundOwnerPackageRefusal) carries no mode test, so the refusal answers a draft save naming Q too. Its text tells the caller to "send this save again" after the delete. Findings ①2 and ①3 stand. ①2 is the save naming no package over legacy rows [package-less, Q], which the address rule routes to Q's row. The dev measures it first in the rework.

The ruling does not change. It is route 1 of the ruling with a premise: refuse before writing, with a prescription that names a real way out. Option B stays not taken. What is owed is that the way out is true in every state the refusal answers:

  1. A draft save naming Q (?mode=draft&package=Q, Studio's designer). The prescription must be true for a draft: a draft save registers nothing, so "delete, then send this save again" leaves the object unserved until a publish. Measure it, then write the true order (for example: move it with a publish-mode save naming Q, then keep drafting). Pin it on a real kernel. The changeset's "becomes the owner at once" applies to the publish-mode save only; say so.
  2. Legacy rows [package-less, Q]. Before this fix, the defect itself stored Q's unserved row beside the package-less owner, so this state exists on deployments that hit 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. Measure what a save naming no package, a save naming Q, and the prescribed DELETE (no package) do there. Make the refusal's prescription name steps that actually work in that state, with no step that deletes the wrong row. Pin it. The changeset's "Unchanged: a save naming no package over the package-less owner" must not be false for this state. Name the boot's changed error text in the changeset as well.
  3. The PR body's Clause-② line becomes Clause-②: no (narrowing). That is Check Changeset's level reading. The dev names this edit and any other body edits in its report, and the seat writes them.
  4. Order with PR fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) #23683 (in the merge queue now): if it lands first, merge main (a merge commit) and re-run its promote-door D9.9 pins beside yours. After this PR, a sentinel owner is a mismatch at the promote doors too.

Notes on the record:

  • The independence note is recorded: the round-1 refusal commit was written under this seat's session. A fresh isolated review follows the rework.
  • The "shared checkout HEAD" note concerns the cloud dev's own container, not this seat's checkout. No action.

Next: the rework goes to the cloud dev session session_01TkXMn8bJqX6ZgMRwuZTxpf now. Then a fresh contract review runs on the new head. The PR does not arm the queue before a PASS.


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

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 origin/main...e0d97ad7e (merge base 69351e985, +823/−5). In protocol.ts at the head I read the cited code:

  • packagelessAddress and packagelessDraftAddress;
  • the address block and the D9.9 block of saveMetaItem, and deleteMetaItem's address and receipt;
  • the promote door in promoteDraftForPublish, which both the per-item publish and publish-drafts reach;
  • applyObjectRegistryMutation, and loadMetaFromDb's object branch;
  • the two server-stated callers of the save door, migrateStoredMetadata and duplicatePackage.

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

  1. RIGHT: the card's door, unchanged since round 2.
  2. RIGHT: the draft door (owed point 1).
    • ?mode=draft&package=Q has its own text: delete the package-less definition, then a publish-mode save naming Q, then draft.
    • That order is true. A draft save registers nothing, and the write-through runs for publish only.
    • It is measured, and pinned on a real kernel across a boot. The changeset's "at once" now applies to the publish-mode save only.
  3. RIGHT: legacy rows [package-less, Q] (owed point 2), for all three requests.
    • A save naming no package is addressed to Q's row by packagelessAddress. It now answers "Delete the 'Q' row first (DELETE …?package=Q), then send this save again". That is true: the resend then edits the package-less definition, and the edit is live.
    • namedNoPackage is read before the address block, so the text names correctly whether the caller named a package.
    • A save naming Q is told the three-step move, with ?package=Q first.
    • Over these rows, the no-package DELETE removes Q's row, so no text prescribes it while that row is stored.
    • Both walks are pinned across a boot, with records kept and nothing dropped.
    • The Unchanged bullet now reads "where only the package-less row is stored", which is true.
  4. RIGHT: the promote doors (owed point 4).
  5. RIGHT: boot and write-through.
    • The verdict is unchanged: the package-less owner is kept, and Q's row is counted in errors.
    • The reason is now the move, ending "The row bound to 'Q' is not registered.". A console.warn spy in the legacy pins asserts it.
    • This holds when the package-less row is found first. loadMetaFromDb reads rows unordered, as the card body records; when Q's row is found first, the boot is unchanged. Not owed.
  6. WRONG: the texts key their state on the NAMED package's row, but the no-package DELETE they prescribe resolves to ANY single package's row.
  7. RIGHT: the server-stated callers of the same door.
    • Two calls now record the item in failed with this text, where before the row was written and then discarded. One is migrateStoredMetadata rewriting a legacy Q row. The other is duplicatePackage copying onto a name that a package-less object holds.
    • Both callers catch errors per item, and D9.9's real-package arm already answers them at the same site.
    • The changeset does not name them. A line may be added; it is not owed.
  8. RIGHT: no new code and no exported surface.
    • UnboundOwnerRefusalState is module-private, and unboundOwnerPackageRefusal is private static.
    • Each text puts the prescription first. With long names, two save texts run past the 500-character REST bound: 517 and 503 characters for customer_invoice_line / com.acme.billing. Only the closing sentence is cut.

② Semver level

  • RIGHT: @objectstack/metadata-protocol: minor, with the BREAKING banner, the ! summary and Clause-②: no (narrowing).
    • It is a narrowing at the save, draft and promote doors, shipped minor in the launch window.
    • The objectql file is a test, so no second entry is owed.
    • Every arm is named FROM → TO with a remedy per state, and the boot's changed text is named.
    • ①6 makes the remedy's state split false for another package's row. That is a text edit, not a level change.
  • RIGHT: ADR-0087 not-required (no-migration-prescription). No stored row, key or export moves, and the remedy is a sequence of requests. The comment now names the no-package arm.
  • Clause-②: the value no (narrowing) is right, since nothing widens. The PR body now carries the same line: owed point 3, which the seat wrote at 18:25Z. Check Changeset re-ran on that edit and concluded success.

③ Boundary flags

  • The four owed points of seat answer 6198103715:
    1. Met (①2).
    2. Met for [package-less, Q] (①3), but not yet for a row bound to another package (①6).
    3. Met (②).
    4. Met. fix(metadata-protocol)!: the promote doors ask ADR-0029 D9.9's question the save door asks (#23630) #23683 is in the merge base (954bf721f under 69351e985), merged by the merge commit 5d74ea946. Its promote texts are adapted (①4), its pin passed 8 tests in the dev's report, and Test Core 1–6 are green on this head.
  • Round-3 open_questions:
    • Q1, which row the legacy move deletes first (name ?package=Q): right for [package-less, Q]. The same rule is owed for every package's row (①6).
    • Q2, one code for every state: right. The ledger meaning holds, and there is no packages/spec fork.
  • Round-3 deviations, all accepted:
    • The seat wrote the PR-body edits. I read them in the body: line 3, the Round 2 section, and both Acceptance notes.
    • The model-free trailers, as in rounds 1 and 2.
    • The scratch probe was deleted before commit; the diff carries three files.
    • The objectql full suite was not re-run locally; Test Core 1–6 concluded success on this head.
  • Round-3 out_of_scope_findings:
  • Independence, recorded and not a gate: this isolated review runs inside session_01GP3comALHRcRBixS2TEnLF. That is the dispatching seat's session, whose round-1 subagent run wrote eca860d38. The round-2 and round-3 commits are the cloud dev session's.

Owed for a PASS. It is narrow, and the card's door and every pinned state stay as they are:

  1. Key the way out on a row bound to ANY package stored beside the package-less one; the save and promote doors already hold packagelessAddress(…).candidates. Name the delete of each such row by ?package= before the package-less DELETE, in the save, draft, promote and boot/write-through texts. Keep the prescription first, as now.
  2. Pin [package-less, P] and a save naming Q: refused with code and status, and with text naming P's delete. The walk in the named order converges across a boot: owner Q, records kept, nothing dropped.
  3. Changeset: the remedy's state split names a row bound to any package, not only Q.

Incidental, not contract: UnboundOwnerRefusalState was inserted between HydrationOptions's JSDoc and its declaration (protocol.ts 2133–2167), so that doc no longer attaches to HydrationOptions. Move the interface above the doc.

Implemented-by: session_01TkXMn8bJqX6ZgMRwuZTxpf
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat answer to the contract review 6199542688 (FAIL at e0d97ad7e9) · domain:engine seat 1 · sunhaolin · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T18:40Z

Verified, not adopted on the record's word. At e0d97ad7e9:

  • Both D9.9 sites compute storedRow as packagelessAddress(…).candidates.includes(layer.packageId). That is the named package Q only.
  • packagelessAddress resolves a request naming no package to the one binding present: packageId is candidates[0] when candidates.length === 1. deleteMetaItem takes that address.
  • So over [package-less, P], the no-stored-row text's "DELETE … with no package parameter" removes P's row, not the package-less definition. Over [package-less, P, Q], the stored-row text's second step does the same.

Finding ①6 stands. The incidental is also verified: UnboundOwnerRefusalState sits between HydrationOptions's JSDoc and its type.

The ruling does not change. The way out must be true in every state the refusal answers, and no step deletes a row other than the one it names (6198103715, point 2). Points 1, 3 and 4 are met, and so is point 2 for [package-less, Q]. What is owed now:

  1. Key the state on a row bound to any package. The doors already hold packagelessAddress(…).candidates. The texts name the ?package= delete of each such row before the package-less DELETE. This applies to the save (publish and draft), promote and boot / write-through texts, prescription first as now.
  2. Measure, then pin [package-less, P] with a save naming Q.
    • The save is refused with code and status, and its text names P's delete.
    • Walk the named order across a boot: owner Q, records kept, nothing dropped.
    • Also measure what the no-package DELETE answers over [package-less, P, Q] (two bindings). Write the text only from what you measured.
  3. Changeset: the remedy's state split names a row bound to any package, not only Q.
  4. Incidental: move UnboundOwnerRefusalState above HydrationOptions's JSDoc.

The dev's class-a finding is filed as #23712, not folded in here. It is the draft save naming no package over legacy rows answering 200 into Q's draft. It predates this PR, and the review left it out of this card's accept set. This PR stays narrow.

Next: the rework goes to the cloud dev session session_01TkXMn8bJqX6ZgMRwuZTxpf now. The dev lists any PR-body edits in its report, and the seat writes them. A fresh isolated review then runs on the new head, and the queue is not armed before a PASS.


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

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 origin/main...fd249aeaa (merge base 53e3638ea, +988/−7). In protocol.ts at the head I read the cited code: packagelessAddress and packagelessDraftAddress; saveMetaItem's address block and D9.9 block; deleteMetaItem's address, its repo.delete call and its heal; the promote door in promoteDraftForPublish and how publishMetaItem and the REST publish route fill packageId; applyObjectRegistryMutation and applyRegistryWriteThrough; and loadMetaFromDb's object branch. I also read the 13 pins and counted every message length 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 20:13 body-edit event (both were success at 18:53). Test Core 1–6, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard and both Check Changeset runs (18:53, and 20:13 after the body edit) are success. Governing text: the seat rulings 6188959051 (refuse with a real way out; option B not taken), 6198103715 (the way out must be true in every state the refusal answers) and 6199585504 (four owed points; no step may delete a row other than the one it names); the card's Wanted; ADR-0029 D3 and D9.9 and ADR-0070 TL;DR 1, as the diff and the rulings quote them.

① Derived judgments

  1. RIGHT: the card's door is unchanged since round 2.
    • A publish save naming Q of an object the sys_metadata sentinel owns answers 422 OBJECT_OVERLAY_PACKAGE_MISMATCH before any write.
    • The real-package arm is byte-unchanged. At all four sites the new packagelessAddress read sits behind ownerPackageId === 'sys_metadata', and the real-package arm passes [] and ignores the state.
  2. RIGHT: owed point 1. The state is keyed on a row bound to ANY package.
    • storedPackages is packagelessAddress(…).candidates at the save door, the promote door, the boot and the write-through. That is every distinct ACTIVE binding, sorted; the text de-duplicates and sorts it again.
    • deleteMetaItem with no state deletes at active only (targetState, passed to repo.delete), so drafts survive every prescribed delete. Naming no package, it takes the active address: the package-less row with no binding, the one row with one binding, and 409 with two.
    • With that, every text names only steps that act on the row they name, in each state the brief lists:
      • [package-less] alone: the publish, draft and promote texts send the no-package DELETE, which removes the package-less row.
      • [package-less, Q]: the save naming Q (publish or draft) gets ?package=Q, then the no-package DELETE, then a publish save naming Q. The save naming no package is addressed to Q and gets "Delete the 'Q' row first".
      • [package-less, P]: the save naming Q gets ?package=P first. The save naming no package is addressed to P and is told to delete P's row (q is the resolved address, the one binding).
      • [package-less, P, Q]: the save naming Q gets ?package=P, then ?package=Q, then the no-package DELETE. The 409 the no-package DELETE answers here is pinned.
      • The boot and write-through: one warning per package's row, each naming every row's delete, asserted with a console.warn spy.
    • Each walk the brief names is pinned on a real kernel across a boot, with owner Q, records kept and drops empty, except the no-package save over [package-less, P], which takes the same branch as the pinned [package-less, Q] case.
  3. WRONG: the promote door's stored-row text names a destination its steps do not reach in one state the door answers. This is by code reading, not measured.
    • The state: legacy rows [package-less, P], a package-less DRAFT stored beside them (drafted before the defect stored P's row), and POST /api/v1/meta/object/NAME/publish naming no package. The REST route omits the packageId key, so promoteDraftForPublish takes packagelessDraftAddress: the active rows are bound to P alone, there is no P draft, and the unbound draft is adopted, giving key: null, into: P.
    • classifyObjectContribution(NAME, P) is a sentinel mismatch, and storedPackages is [P]. The text reads "To move it into 'P': DELETE …?package=P, then the same DELETE with no package and no dropStorage …, then publish this draft again."
    • Followed: once P's active row is gone, the same publish addresses key: null, into: null and promotes the draft into the package-less row. The object is live but package-less, not in P. The middle delete was superfluous. No wrong row is deleted and nothing is dropped.
    • So "To move it into 'P'" is false, and the ruling bars it ("the way out true in every state the refusal answers"). An AI following it would report the object moved into P while it sits in the package-less state ADR-0070 retires. A publish re-sent naming P finds no P draft.
    • This is the same class as round 2's ①6 at the save door: a request naming no package that the address rule routes to a package's binding. The save door carved it (namedNoPackage); the promote door has no such arm.
    • No pin covers it: every promote pin publishes naming Q explicitly. The same holds with Q in P's place.
  4. RIGHT: applyObjectRegistryMutation became async, and so did its caller.
    • Its only call site is applyRegistryWriteThrough, which awaits it. That method was already async, and all seven of its call sites await it.
    • The new read sits inside the existing try, so a failed read lands in the same best-effort warn.
    • registerItem and the non-mismatch registerObject still run before any await, so registration timing is unchanged.
    • The call-site-count pin (protocol.object-registry-write-through-spelling, 12 passed) is unaffected. The one test that stubs the method with a sync function still works under await.
    • The method is private, so there is no surface change.
  5. RIGHT: the 500-character REST bound, recounted from the literals with customer_invoice_line / com.acme.billing / com.acme.crm.
    • [P, Q] save: 452. Promote [P, Q]: 427. No-package save: 422. Draft: 476. One stored row: 398–406.
    • The publish text is 503, unchanged since round 2. Only "ed." of its closing explanatory sentence is cut.
    • The boot line is 486 and is a log, so the bound does not apply.
  6. RIGHT: no new code and no exported surface.
    • UnboundOwnerRefusalState is module-private. It now sits above HydrationOptions's JSDoc, so that doc attaches again (owed point 4).
    • unboundOwnerPackageRefusal is private static.

② Semver level

  • RIGHT: @objectstack/metadata-protocol: minor, with the BREAKING banner, the ! summary and Clause-②: no (narrowing).
    • It narrows the save, draft and promote doors and ships minor in the launch window.
    • The objectql file is a test, so no second entry is owed.
  • RIGHT: owed point 3. The remedy now names a row bound to any package, and it states both measured answers of the no-package DELETE (it removes the one row; 409 over two).
    • Its promote bullet is scoped to "Q's draft", so ①3 does not falsify it.
    • If ①3's text changes, the changeset names that arm.
  • RIGHT: ADR-0087 not-required (no-migration-prescription). No stored row, key or exported declaration moves, and a private method turning async emits no typed surface.
  • Clause-②: the value no (narrowing) is right, since nothing widens. The PR body carries the same line, and Check Changeset concluded success on both runs.

③ Boundary flags

  • The four owed points of seat answer 6199585504:
    1. Met (①2). ①3 is a residual at the promote door.
    2. Met. [package-less, P] with a save naming Q is measured and pinned, and its walk converges across a boot. The 409 over two bindings was measured before the text was written, and is pinned.
    3. Met (②).
    4. Met (①6).
  • Round-4 open_questions, Q1 (each package's row deleted by name first; never rely on the no-package DELETE while a package's row is stored): answered right, and it holds for every save state.
  • Round-4 deviations, all accepted:
    • The seat wrote the body edits. I read them in the body: the Round 3 section and both Acceptance notes.
    • The model-free trailers, as in earlier rounds.
    • The scratch probe was deleted before commit; the diff carries three files.
    • check:dual-build-cjs-loads exited 3 on a missing build prerequisite, then 0 after a full build.
    • Two declared narrowings: no HTTP walk this round (the new states are protocol-level pins on the same door), and no local objectql full suite (Test Core 1–6 are green on this head).
  • Round-4 out_of_scope_findings:
  • ①3 is this diff's own text, so it is escalated into this verdict's rework, not a new card.
  • Noted, not owed: main has moved past the merge base (9e0945cf4, carrying objectql/mcp: on an environmentId kernel (os dev env_local, the cloud runtime) a runtime-authored object may have no metadata-service copy, so the MCP exposure gate would read apiEnabled false as no declaration (not walked) #23689's runtime-object metadata copy, a neighbour pin's subject). The queue re-tests the merge.
  • Independence, recorded and not a gate: this isolated review runs inside session_01GP3comALHRcRBixS2TEnLF. That is the dispatching seat's session, whose round-1 subagent run wrote eca860d38. The round-2 to round-4 commits are the cloud dev session's, on branch claude/issue-23514-sentinel-owner-save.

Owed for a PASS. It is narrow, and the card's door and every pinned state stay as they are:

  1. Measure, then fix the text for the promote door's publish naming no package whose address adopts the package-less draft into the one stored binding (key: null, into: P). Make it say what its steps do, or make its destination true. For example, delete P's row by name, then publish again, and it promotes into the package-less definition. Keep the prescription first, within the bound.
  2. Pin it on a real kernel: [package-less, P] plus a package-less draft, and publishMetaItem naming no package. The publish is refused with code, status and text, and the walk ends as the text says, across a boot, with nothing dropped.
  3. Changeset: name that arm if its text changes.

Implemented-by: session_01TkXMn8bJqX6ZgMRwuZTxpf
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat answer to the contract review 6201547904 (FAIL at fd249aeaa9) · domain:engine seat 1 · sunhaolin · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T20:25Z

Verified, not adopted on the record's word. At fd249aeaa9, promoteDraftForPublish with no package takes packagelessDraftAddress. With one active binding P and no P draft, the address is the package-unbound draft with into: P, so promoteInto is P. The D9.9 check refuses with the move text "into P … then publish this draft again". After the two deletes no active row is left. The same publish then resolves into: null (an item never published into a package: the package-unbound draft), and the object lands package-less, not in P. No wrong row is deleted and nothing is dropped. But the text names a destination its steps do not reach, which is the same class as round 2's ①6. Finding ①2 stands.

The ruling does not change. Every text names steps that reach what it says. Points 1–4 of 6199585504 are met everywhere else. What is owed now is narrow:

  1. Measure first: active rows [package-less, P] plus a package-less draft, published with no package. Read what each candidate way into P actually does across a boot. For example: after the deletes, a publish naming P; or a save naming P in publish mode. Then write the promote text for this adopted-draft arm (draftKey null, into: P) from what you measured. If the honest outcome is that the draft lands package-less, the text says so and names the measured next step into P.
  2. Pin it on a real kernel across a boot: refused (code, status), the walk in the named order, and the object ending where the text says, with records kept and nothing dropped.
  3. Changeset: name the arm if its text changes.

Nothing else moves. The card's door, the other states and their pins stay as they are.

Next: the rework goes to the cloud dev session session_01TkXMn8bJqX6ZgMRwuZTxpf now (round 5). Body edits are listed in the report, and a fresh isolated review follows on the new head.


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 814ee78fb80f17214f38a42fe7038c889ff29066
Local-runs: none

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 origin/main...814ee78fb (merge base f40d877f8, +1074/−7). I also read the interdiff of the round-4 net diff (fd249aeaa over 53e3638ea) against this one, and git show --cc of both main merges (0375a53f6, 814ee78fb) on the three PR files. At the head I read the cited code: packagelessAddress, packagelessDraftAddress and unboundItemDraftAddress (which #23706 merged in), classifyObjectContribution, promoteDraftForPublish and how publishMetaItem and the REST publish route fill packageId, deleteMetaItem's address and its repo.delete at targetState, and resolveWriteHead's orphan-draft adoption. I also read the 14 pins. I counted the new text's length from its literals; that was a string-length count, and no repository code was run. The head has 47 check-runs: 39 success, 8 skipped, 0 failed, 0 in progress.

  • The skipped ones: five by path filter, the opt-in tarball smoke, and Auto Label and Check PR Size on the 22:12 body-edit event (both were success at 21:05).
  • Success: Test Core 1–6 and its aggregate, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard, and both Check Changeset runs (21:05, and 22:12 after the body edit).

Governing text:

  • the seat rulings 6188959051 (refuse with a real way out; option B not taken), 6198103715 (the way out true in every state the refusal answers, legacy rows included), 6199585504 (no step deletes a row other than the one it names) and 6201560378 (every text names steps that reach what it says, plus this round's one owed item);
  • the card's Wanted;
  • ADR-0029 D3 and D9.9, and ADR-0070 TL;DR 1, as the diff and the rulings quote them.

① Derived judgments

  1. RIGHT: the one item 6201560378 owed is met. That is the promote door's adopted-draft arm: active rows [package-less, P], a draft bound to no package, and a publish naming no package, with draftKey null and into: P.
    • Detection: adoptedDraft is !('packageId' in request) && draftKey === null && promoteInto !== null. The same 'packageId' in request test is what selects packagelessDraftAddress, and that address returns key: null with a non-null into only in its one-binding arm (no P draft, plus an unbound draft). The explicit-package branch sets promoteInto = draftKey, and the bound-to-none arm sets into = key. So the flag is true in exactly the seat's state, and storedPackages is then always [P].
    • The text against the head's address rules, with fix(metadata-protocol)!: a draft save naming no package, of an item never published into a package, takes the draft doors' address (#23657) #23706 merged:
      • DELETE …?package=P removes the active row only, so the draft survives.
      • The same publish then sees no active binding. unboundItemDraftAddress takes the unbound draft (key: null, into: null), classifyObjectContribution(NAME, null) answers own, and the draft lands in the package-less definition, as the text says.
      • The no-package DELETE then removes that row, and the publish-mode save naming P makes P the owner.
    • Measured first on 0375a53f6 (C1, C2, C5 in 6202676437).
    • Pinned on a boot-hydrated kernel, since seedLegacyRows reboots:
      • the refusal: code, status, packageId, the first sentence by regex, and both step sentences;
      • after the publish: owner sentinel, "Gadget unbound draft", 2 records;
      • after the move: owner P, 2 records;
      • a further boot agrees, with rows [P] and drops empty.
    • The dev's ablation of the arm read 1 failed / 13 passed, exactly the new pin.
    • Length: 445 characters with probe names and 489 with long names, inside the 500-character REST bound. The first sentence states the address, and the steps follow.
    • The changeset names the arm (a sub-bullet under the promote bullet) and adds its remedy clause.
  2. RIGHT: nothing else in the refusal texts or their pins moved since round 4.
    • The interdiff of the two net diffs is additions only, plus three replaced lines:
      • the if that became else if behind the new arm;
      • the promote site's state object, which gained adoptedDraft;
      • the changeset's Remedy, extended with the new state.
    • Every other text, the real-package arm and the 13 earlier pins are byte-identical. There are 14 cases now.
  3. RIGHT: the two main merges brought no conflict into the PR's files. git show --cc prints nothing for the three files on either merge.
  4. WRONG: the promote door's stored-row text names a destination, and a draft, that its steps do not reach in one more state of the same class. This is by code reading, not measured.
    • The state:

      • legacy active rows [package-less, P];
      • BOTH P's draft and a draft bound to no package stored;
      • a publish naming no package (POST /api/v1/meta/object/NAME/publish without ?package=, which the REST route sends with no packageId key).
    • With one binding, packagelessDraftAddress prefers P's draft (key: P, into: P). D9.9 refuses for the sentinel owner, adoptedDraft is false, and storedPackages is [P]. The text reads "…its definition bound to 'P' is never served. To move it into 'P': DELETE …?package=P, then the same DELETE with no package and no dropStorage (the table and records stay), then publish this draft again."

    • Followed:

      • Both deletes remove active rows only, so both drafts survive.
      • The same publish now finds no active binding, and unboundItemDraftAddress prefers the unbound draft (key: null, into: null).
      • classifyObjectContribution(NAME, null) answers own, so the OLDER package-less draft goes live in the package-less definition.
      • P's draft, the one that was refused, stays stored and unpublished.

      The object is not in P, and the body served is not the one the caller published. The changeset's Remedy ("then the same publish; the draft's promotion makes Q's row the owner") is false here too.

    • This is the class 6201560378 bars, and the same class as round 4's ①3: a request naming no package whose address moves once P's active row is gone. The adopted arm was carved; the arm where P's own draft is the address was not.

    • It is reachable on the deployments 6198103715 point 2 put in scope. An author's package-less draft is stored, then the pre-fix door stores P's active row and P's draft from a draft save naming P. resolveWriteHead orphan-adopts only when the caller named no package, so an explicit P draft sits beside the unbound one.

    • Not pinned: every non-adopted promote pin publishes naming its package (packageId: Q).

    • Same family, not separately owed: over P's and Q's drafts with no unbound one, the same re-sent publish answers 409 (ambiguous). That is loud, and naming the package in the final step closes both.

  5. RIGHT, carried from round 4 and re-read in this diff:
    • The card's door: a save naming a package over a sentinel owner answers 422 OBJECT_OVERLAY_PACKAGE_MISMATCH before any write.
    • The real-package arm is byte-unchanged behind ownerPackageId === 'sys_metadata'.
    • The draft door, the save-door texts over legacy rows (the no-package arm included), and the boot and write-through texts.
    • applyObjectRegistryMutation is async, with its one caller awaiting it.
    • No new error code. UnboundOwnerRefusalState is module-private, and adoptedDraft is a new optional field on it. unboundOwnerPackageRefusal is private static. There is no exported surface.

② Semver level

  • RIGHT: @objectstack/metadata-protocol: minor, with the BREAKING banner, the ! summary and Clause-②: no (narrowing).
    • This round changes the message of an existing refusal inside an existing narrowing (the promote door's sentinel mismatch). That is not a level change.
    • The objectql file is a test, so no second entry is owed.
  • RIGHT: the changeset names the new arm FROM → TO and its remedy. ①4's fix changes one remedy clause, not the level.
  • RIGHT: ADR-0087 not-required (no-migration-prescription). No stored row, key or exported declaration moves, and the new field sits on a module-private interface.
  • Clause-②: the value no (narrowing) is right, since nothing widens. The PR body carries the same line, and Check Changeset concluded success on both runs on this head.

③ Boundary flags

Owed for a PASS. It is narrow; the card's door, the adopted arm and every pinned state stay as they are:

  1. Measure, then fix the promote door's stored-row text for a publish naming no package whose address is a package's own draft while a draft bound to no package is also stored. One way: when the caller named no package, the last step names the package ("then publish this draft again naming 'P' (POST …/publish?package=P)"). That reaches P's draft whatever unbound draft remains. The other way: carve the state and say where the steps land. Keep the prescription first, within the 500-character bound.
  2. Pin it on a real kernel: [package-less, P], plus P's draft and a draft bound to no package, with publishMetaItem naming no package. It is refused with code, status and text. The walk in the named order ends with owner P and P's draft body served, records kept, a boot agreeing, and nothing dropped.
  3. Changeset: the promote remedy's "then the same publish" matches the new text.

Implemented-by: session_01TkXMn8bJqX6ZgMRwuZTxpf
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat answer to the contract review 6203512210 (FAIL at 814ee78fb8) · domain:engine seat 1 · sunhaolin · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T22:24Z

Verified, not adopted on the record's word. packagelessDraftAddress's own contract:

  • With one active binding P, the address is P's draft, else the package-unbound draft.
  • With no active binding, it is the package-unbound draft when one is stored, else the one package's draft.

Over legacy rows [package-less, P] with both P's draft and a package-unbound draft stored, a publish naming no package therefore addresses P's draft and is refused. The text says "… then publish this draft again". After the two deletes, the same publish naming no package addresses the unbound draft instead: the older draft goes live package-less, and P's draft stays unpublished. Finding ①2 stands.

Root cause, not one more state. This is the third round in a row to find one more state in the promote door's text matrix. The cause is the same each time: a text's last step is a request naming no package. What that request resolves to depends on the rows the earlier steps leave behind. So this round closes the class, not the state:

  1. Name the package in every publish step whose draft is bound to one. Wherever the promoted draft has a package key, the prescribed publish names it (?package=P). After the deletes, that request addresses P's draft by name, whatever else is stored. Keep the adopted-draft arm's round-5 text (draft bound to no package) as it is: it is measured and true.

  2. Measure the matrix once, then stop. Over the promote door, measure every combination of:

    • active rows [package-less], [package-less, P], [package-less, P, Q];
    • stored drafts none, unbound, P, unbound + P;
    • a publish naming no package, P, or Q.

    In the report, give one table row per refused combination: the text arm it gets, and where the named walk actually lands (measured), across a boot. A text whose walk does not reach what it says is fixed before the report. Pin each arm the matrix shows to be new or changed. The table is the evidence a reviewer reads instead of searching for the next state.

  3. The changeset's promote remedy matches the texts.

Nothing else moves: the card's door, the save-door texts, and the adopted-draft arm stay as round 5 left them. Not in scope: #23712.

Next: the rework goes to the cloud dev session session_01TkXMn8bJqX6ZgMRwuZTxpf now (round 6). Body edits are listed in the report. A fresh isolated review then judges the head against the matrix table.


Generated by Claude Code

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 9a259bd7d21e122543a6c178d8f4fbccd45aedb7
Local-runs: none

Read: card #23514's body and all thirteen comments (6172542441 through the dev's round-6 report 6204057324, its promote_matrix table included); PR #23704's body as the seat refreshed it at 23:08Z, its nine comments (above all the round-5 record 6203512210 and the seat's answer 6203522045), its three files, and the net diff origin/main...9a259bd7d (merge base 9bb93706f, +1129/−7). I also read the round-6 interdiff 814ee78fb..9a259bd7d on the three files, and git show --cc 2e4ade078 (the main merge) on them. At the head I read the cited code:

  • packagelessAddress, packagelessDraftAddress, and unboundItemDraftAddress in sys-metadata-repository.ts;
  • promoteDraftForPublish: its address, its D9.9 site and its repo.promoteDraft call;
  • deleteMetaItem: its address, its repo.delete at targetState, and its heal;
  • unboundOwnerPackageRefusal, and the 15 pins.

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.

  • Skipped: five by path filter, the opt-in tarball smoke, and Auto Label and Check PR Size on the 23:08 body-edit event. Both of those two were success at 22:35.
  • Success: Test Core 1–6 and its aggregate, Build Core, TypeScript Type Check, Lint & Repo Gates, Governed Surface Queue Guard, and both Check Changeset runs (22:35, and 23:09 after the body edit).

Governing text:

  • the seat rulings 6188959051 (refuse with a real way out; option B not taken), 6198103715 (the way out is true in every state the refusal answers), 6199585504 (no step deletes a row other than the one it names) and 6201560378 (every text names steps that reach what it says);
  • 6203522045: close the class. Every publish step whose draft is bound to a package names that package, the 36-cell matrix is the evidence, and nothing else moves;
  • the card's Wanted;
  • ADR-0029 D3 and D9.9, and ADR-0070 TL;DR 1, as the diff and the rulings quote them.

① Derived judgments

  1. RIGHT: the class is closed at the promote door (6203522045 point 1).
    • Three texts reach at: 'promote': the adopted-draft arm, the stored-rows arm and the no-stored-row arm. namedNoPackage is never passed there.
    • The stored-rows and no-stored-row arms now end then POST /api/v1/meta/object/NAME/publish?package=Q. Here Q is layer.packageId, the promoteInto that the D9.9 check judged.
    • In both arms promoteInto is the promoted draft's own binding:
      • With 'packageId' in request, the door sets promoteInto = draftKey.
      • Naming no package, packagelessDraftAddress returns into = key, except in its one-binding unbound-draft case (key: null, into: P). That case is exactly adoptedDraft, and it takes the adopted arm.
      • The adopted case needs an active binding, so storedPackages is never empty there. The no-stored-row arm therefore cannot meet an adopted draft.
    • So after the deletes, the last step reads that draft as repo.get(..., { state: 'draft', packageId: Q }), whatever else is stored.
    • The only request in those texts that names no package is the DELETE. It is sent only after every package's active row has been deleted by name. With no active binding left, packagelessAddress resolves it to the package-less row, and that is the only address the row has.
    • The adopted-draft arm's "publish again" names no package, as the ruling's own carve-out allows. By code reading it holds beyond the matrix too: once P's active row is gone, unboundItemDraftAddress returns the unbound draft whenever one is stored, whatever package drafts sit beside it.
  2. RIGHT: the matrix's 10 refused cells, and the text arm each one gets, are what the code at this head produces.
    • A publish naming P is refused exactly when P's draft is stored: 6 cells. storedPackages is [], [P] or [P, Q], which gives the no-stored-row arm, the stored-rows arm, and the stored-rows arm naming both deletes in order.
    • A publish naming Q finds no Q draft under any drafts value, so all 12 cells answer NO_DRAFT.
    • A publish naming no package:
      • Over [package-less], unboundItemDraftAddress gives the unbound draft whenever one is stored. With U or U+P stored, the draft publishes package-less, because classifyObjectContribution answers own. P's draft alone is refused with the no-stored-row text (1 cell). With no draft, it answers NO_DRAFT.
      • Over [package-less, P], P's draft is the address when stored (P, U+P), and gets the stored-rows text: 2 cells, the review's cell among them. The unbound draft alone takes the adopted arm (1 cell). With no draft, it answers NO_DRAFT.
      • Over [package-less, P, Q], the address is ambiguous, so all four cells answer 409.
    • That is 4 + 6 = 10 refused and 26 not refused. The 26 split as the report states: 12 naming Q, 6 naming P with no P draft, and 3 + 1 + 4 naming no package.
  3. RIGHT: each walk lands where its row claims, by the head's rules.
    • deleteMetaItem with no state removes the active row only (targetState is passed to repo.delete), so every draft survives every prescribed delete.
    • A delete naming P removes P's active row, and the package-less row keeps the sentinel owner (pinned at line 801 of the pin file). The no-package DELETE then removes the package-less row, and fix(metadata-protocol): deleting a runtime object hydrated at boot unregisters it at once; the metadata service's boot copy is not its code layer #23659's heal leaves no owner.
    • The publish naming P then reads P's draft. classifyObjectContribution finds no owner and answers own, and the promotion registers P. The result: owner P, "Draft P" served, and the unbound draft left stored as a draft where one was stored.
    • The adopted cell lands package-less after ?package=P, then in P after the publish-mode save, as round 5 measured.
    • Pinned:
      • the review's cell, as a new case: the refusal's code, status, packageId and named step; the walk; owner P and "Gadget P draft" with 2 records; a boot that agrees; drops empty;
      • the stored-rows promote naming Q (the case at line 623) and the no-stored-row promote (line 818), both updated to the named step.
    • The dev's ablation of the step read 2 failed / 13 passed.
    • Length: the longest promote text, with customer_invoice_line / com.acme.billing / com.acme.crm, is 482 characters, inside the 500-character REST bound. The adopted arm stays at 489.
  4. RIGHT: nothing else moved.
    • In protocol.ts, the interdiff 814ee78fb..9a259bd7d holds two refusal changes: the stored-rows arm's finish, and the no-stored-row promote sentence. The save branch of that finish (save it naming 'Q' in publish mode) is byte-identical.
    • Also byte-identical: the save door's publish, draft and no-package texts, the adopted-draft arm, the boot and write-through tail, and the real-package arm.
    • The rest of that interdiff is main's fix(metadata-protocol): an orphan adoption rebinds a draft view in storage only, never registering it live #23724, an active-only gate in reassignOrphanedMetadata, outside the refusal. git show --cc 2e4ade078 prints nothing for the three files, so the merge resolved no conflict there.
    • The pin file gains one case and updates two assertions to the named step. The other 12 cases are unchanged.
  5. RIGHT, carried and re-read:
    • the card's door: a save naming a package over a sentinel owner answers 422 OBJECT_OVERLAY_PACKAGE_MISMATCH before any write;
    • the draft door, the save texts over legacy rows, and the boot and write-through texts;
    • applyObjectRegistryMutation is async, and its one caller awaits it;
    • no new error code. UnboundOwnerRefusalState is module-private and unboundOwnerPackageRefusal is private static, so there is no exported surface.

② Semver level

  • RIGHT: @objectstack/metadata-protocol: minor, with the BREAKING banner, the ! summary and Clause-②: no (narrowing).
    • This round changes the message of an existing refusal inside the existing narrowing (the promote door's sentinel mismatch). That is not a level change.
    • The objectql file is a test, so no second entry is owed.
  • RIGHT: the changeset's promote remedy matches the texts.
    • The promote bullet quotes "then POST /api/v1/meta/object/NAME/publish?package=Q: the draft then serves the object at once". For stored rows it says "then the same POST …/publish?package=Q", and it says why the step names the package.
    • The Remedy reads "the same deletes for its state, then POST /api/v1/meta/object/NAME/publish?package=Q", "whether the request named Q or the address resolved to it".
    • The adopted-draft clause ("then the same publish (the draft lands package-less)") is unchanged, and still true.
  • RIGHT: ADR-0087 not-required (no-migration-prescription). No stored row, key or exported declaration moves.
  • Clause-②: the value no (narrowing) is right, since nothing widens. Line 3 of the PR body carries the same line, and Check Changeset concluded success on both runs on this head.

③ Boundary flags

Implemented-by: session_01TkXMn8bJqX6ZgMRwuZTxpf
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Head: 9a259bd7d21e122543a6c178d8f4fbccd45aedb7.

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:


Generated by Claude Code

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