Skip to content

fix(metadata-protocol)!: the per-item publish and the draft discard that name no package act on the draft the draft save writes (#23495) - #23654

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23495-draft-doors-one-address
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-23495-draft-doors-one-address

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Fixes #23495
Clause-②: yes (narrowing)

What this does

A draft door that names no package on /api/v1/meta/TYPE/NAME now acts on one draft: the draft that the draft save naming no package writes (#11087, resolveWriteHead). The strict draft read and the draft preview have served that draft since #23291. The per-item publish (POST …/publish) and the draft discard (DELETE …?state=draft) now take the same address, through one helper, packagelessDraftAddress in packages/metadata-protocol/src/protocol.ts. Where the draft save naming no package is refused, both are refused 409 RESOURCE_CONFLICT with the ?package= remedy. The refusal comes from the same builder, packagelessAddressAmbiguityRefusal, which gains a publish verb. The draft save's receipt now names the package it inherits.

the item's active rows are bound to draft save naming no package strict draft read publish naming no package (this PR) discard naming no package (this PR)
one package P P's draft, else adopts the package-unbound draft into P P's draft, else the package-unbound draft the same draft, promoted into P's active row the same draft
one package P, no draft at that address creates P's draft 404 NO_DRAFT 404 NO_DRAFT, naming the other packages' drafts and ?package= 200, reset: false; the receipt names the drafts that remain
two or more packages 409 RESOURCE_CONFLICT best effort (ADR-0048 §3.3) 409 RESOURCE_CONFLICT 409 RESOURCE_CONFLICT
none (never published into a package) the package-unbound draft best effort the package-unbound draft, else the one package's draft; two packages' drafts: 409 the same

Governing text: ADR-0070 TL;DR 1 (the package-less state is no authoring destination); ADR-0048 §3.3 (a context-free read is best-effort); #23165 / PR #23290 (a package-less save or delete addresses the one bound package, refused 409 when ambiguous); ADR-0005, read as the card reads it: the draft read's 404 and the publish's empty slot mean the same thing.

Measured first: the card's probe, on a real stack

The probe ran on a real stack: better-sqlite3 :memory:, the real ObjectQL, the real sys_metadata* objects, the real ObjectStackProtocolImplementation, and the real RestServer routes. It is the harness of packages/rest/src/meta-draft-head-package-inheritance.test.ts. Each door was driven on its own fresh boot, built through the doors. main is 23e689dc7; the fix is b411a4c54.

State 1: active row bound to P, drafts [package-unbound, P]. It is reachable through the doors in both arrangements: draft-first then package, or P's draft first. On SQLite, the store's find returns the package-unbound row first in both arrangements.

door main this PR
GET ?state=draft P's draft P's draft
PUT ?mode=draft writes P's draft; receipt (env-wide, state=draft) writes P's draft; receipt (env-wide, package=P, state=draft)
POST …/publish promotes the package-unbound draft into a package-unbound active row: active rows [null, P] promotes P's draft into P's row; the package-unbound draft stays
DELETE ?state=draft removes P's draft removes P's draft

State 2: active row bound to P, the only draft bound to Q. It is reachable through PUT ?mode=draft&package=Q.

door main this PR
GET ?state=draft 404 NO_DRAFT 404 NO_DRAFT
PUT ?mode=draft creates P's draft creates P's draft
POST …/publish 200, promotes Q's draft into a Q active row; the item then has two packages, and every package-less save or delete is refused 409 404 NO_DRAFT, naming Q's draft and ?package=; nothing written
DELETE ?state=draft removes Q's draft 200 reset:false, "… its draft bound to package 'Q' remains"; nothing removed

The probe also covered the corners around the card's states:

  • The package-unbound draft is the only draft of an item bound to P. On main, the publish promoted it into a package-unbound active row beside P's, which no read naming no package serves. This PR promotes it into P's row, as the save adopts it. This state follows directly from state 1 once P's draft is published.
  • Active rows bound to P and Q, one draft. The save is refused 409. On main, the publish and the discard acted on that draft. This PR refuses all three with 409.
  • No active row, drafts [package-unbound, Q]. On main, the discard removed Q's draft while the save wrote the package-unbound one. This PR acts on the package-unbound draft.
  • No active row, drafts [Q] only. It is unchanged: both doors act on Q's draft.

The full probe matrix and both runs are in the dev report on #23495.

Each of the three hypotheses was measured against the source and the probe. H1 held: the draftKey === undefined branch read a package-agnostic findEnvironmentRow. H2 held: the discard resolved from packagelessAddress(…, 'draft'). H3 held: the receipt read (env-wide, state=draft) with the package inherited.

The decisions this PR takes, and why

  1. The address is the draft save's, not the draft rows'. For an item bound to P, that is P's draft, else the package-unbound draft. The read, the save, the token and now the publish and the discard all name that one row.
  2. A package-unbound draft of an item bound to P is published into P. This mirrors the save's orphan adoption. Promoting it package-unbound minted an active row that no read naming no package serves, which is the defect class metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 closed for the save. It makes the package-less state an authoring destination, which ADR-0070 TL;DR 1 rules out. fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489's body names this case as this card's. The repository gains promoteDraft({ adoptInto }): only an unbound draft row is adopted, a bound one keeps its binding, and the drain removes the draft row by its own key.
  3. No address at P gives 404 NO_DRAFT, the code and status the strict draft read answers. The publish's sentence names the drafts that remain and the request that reaches them. The discard keeps its 200 reset:false receipt shape for an address that holds no row.
  4. For an item never published into a package, both doors act on the package-unbound draft the save writes. If there is none, they act on the one package's draft, the only draft the best-effort read can serve. This keeps a client that drafts a new item with ?package=Q and publishes naming no package working: client.meta.publishItem cannot name a package. Two packages' drafts with no package-unbound one are refused 409 instead of a storage-order pick. This corner is outside the card's two states; see Open questions in the report.
  5. packageId: null on the publish (in process only) still pins the package-unbound draft, as PublishMetaItemRequestSchema states. Only the ABSENT key changed meaning.

Narrowing: requests that answered 200 and no longer do

  • A publish or a draft discard naming no package, of an item whose active rows two or more packages hold, is now 409 RESOURCE_CONFLICT. With no active row and drafts in two or more packages (no package-unbound draft), it is also 409.
  • A publish naming no package, of an item bound to P whose only draft is another package's, is now 404 NO_DRAFT. It used to promote that draft.

Both have their remedy in the request: name the package with ?package=. The changeset is BREAKING, shipped minor under the launch-window convention, carries FROM, TO and the remedy, and its ADR-0087 marker is not-required (no-migration-prescription). A narrowing owes the seat a contract-tier review before the queue.

Widening: a request that answered 409 and now answers 200

  • A draft discard naming no package, of an item whose active rows are bound to one package P while its drafts are bound to two or more packages, was refused 409 RESOURCE_CONFLICT: the address was read off the draft rows, as the published DeleteMetaItemRequestSchema.packageId description stated. It now removes P's draft, else the package-unbound draft — the draft the draft save naming no package writes. The same holds for an item never published into a package with a package-unbound draft beside two or more packages' drafts.

Named by the contract review 6190278990 (① item 11). The domain:engine seat 1 ruled the line Clause-②: yes on it, at session_01GP3comALHRcRBixS2TEnLF, and the changeset carries the same declaration and names the former 409 in its FROM block.

Landing points outside the claimed surface (declared)

The claim's surface is packages/metadata-protocol/src/protocol.ts, the tests and the changeset. Measurement needed five more files:

Tests

Every run below is at c313d36d7.

  • packages/metadata-protocol/src/protocol.packageless-save-address.test.ts gains a new [#23495] describe with 8 cases over the stub engine:
    • state 1 in both storage orders: the save, read, publish and discard each act on the draft the save writes, and the receipt names P;
    • state 2: the publish answers 404 NO_DRAFT (envelope code/status), the discard removes nothing, and ?package=Q reaches the draft;
    • the package-unbound draft promoted into P;
    • two packages: 409, nothing written;
    • an item never published into a package: both orders of [package-unbound, Q], [Q] alone, and [Q, R] refused 409.
  • sys-metadata-repository.draft-package-inherit.test.ts gains 3 adoptInto pins, and meta-draft-head-package-inheritance.test.ts (rest, real stack) gains 3.
  • Two pins of the removed first-row pick were replaced, not respelled, each with a comment saying why:
    • protocol-publish-drafts-package-scope.test.ts "keeps the historical match-any resolution…" is now the 409, plus a case pinning that an absent key is not a present null;
    • protocol.org-scoped-write-refused.test.ts "…consults the lock of the draft row's own package" is now parity with the lock the draft save naming no package is judged by.
  • The full @objectstack/metadata-protocol suite ran in two halves: 236 files passed and 3 skipped; 20563 tests passed and 19 skipped.
  • Consumer suites, every file that drives a draft door:
    • objectql: 20 files, 340 tests;
    • rest: 29 files, 1481 tests;
    • runtime: 13 files, 1115 tests.
      All green after the objectql double fix.
  • Typecheck exited 0 for metadata-protocol, rest (with check:test-typecheck), objectql (with check:test-typecheck) and spec. tsc --listFiles in metadata-protocol includes all 4 edited test files.

Ablations. Each ran through scripts/ablation-replace.mjs, which landed the mutation on disk and restored the file afterwards with blob equal to HEAD and an empty git diff HEAD. All went red, the expected direction:

  1. Draft address ignores the active rows (protocol suite, src-resolved): 6 red: the five [#23495] cases over an item with active rows, and the replaced lock pin. The cases for an item never published into a package stayed green, as predicted.
  2. adoptInto removed in the repository: 2 red, the protocol adoption case and the repository pin.
  3. The receipt line deleted: 2 red, both state-1 orders.
  4. Ablation 1 on the dist-resolved REST suite:
    • the mutation was rebuilt into dist/, and ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs;
    • 3 of 15 tests went red;
    • restore leg: rebuilt, preflight --absent held, the tree was clean, and 15 of 15 passed.

Gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run at c313d36d7, derived 113 commands. All 113 ran locally, each exit code captured before any pipe. --ran reconciliation: 113 derived, 113 accounted for.

  • 109 exited 0. They include:

    • check:generated: all 14 artifacts up to date;
    • check:docs, check:api-surface and check:engine-double-contract;
    • check:durability-log-level and check:doc-authoring;
    • check-adr-0087-registration, which reads the changeset as [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription);
    • check-changeset-no-major and check-empty-changeset.
  • NOT MEASURED, all of them prerequisite refusals in this container rather than findings:

    • check-plugin-teardown-shape --self-test (exit 3): a shallow clone, missing its pinned positive control;
    • check:skill-examples (exit 3): client-react is not built;
    • check:dual-build-cjs-loads (exit 3): the full repo is not built;
    • check-engine-split-ratio --days 90 (exit 2): a shallow clone, refusing a windowed metric.

    CI runs all four.

  • main moved during the run. A local merge-tree against c7dd48ab4 is clean, with no conflict. Following the seat's instruction, main is not merged.

Acceptance notes

…at name no package act on the draft the draft save writes

WIP: the draft address helper, the publish and discard routing, the
orphan-draft adoption on promotion, the publish arm of the shared
ambiguity refusal, and the draft-save receipt naming the inherited
package. Tests and changeset follow.

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…n the draft the draft save writes

Pins both card states in both storage orders, the package-unbound draft
promoted into the item's package, two packages refused 409, an item never
published into a package, the draft-save receipt, and the repository's
adoptInto. Two pins of the removed first-row pick are replaced.

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…t door naming no package acts on; changeset

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
…d, which the draft address reads

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
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 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 16 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via meta.publishItem (sdk, the route ledger binds it to POST /api/v1/meta/:type/:name/publish, selected by route anchor /:type/:name/publish), publishItem (sdk, the bare tail of client method meta.publishItem, bound to POST /api/v1/meta/:type/:name/publish))
  • content/docs/api/metadata-api.mdx (via /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via publishMetaItem (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/cluster.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/contracts/metadata-service.mdx (via packageId (literal, a string literal in promoteDraftForPublish), /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via deleteMetaItem (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation), packageId (literal, a string literal in promoteDraftForPublish))
  • content/docs/permissions/capabilities.mdx (via packageId (literal, a string literal in promoteDraftForPublish))
  • content/docs/permissions/permission-sets.mdx (via packageId (literal, a string literal in promoteDraftForPublish))
  • content/docs/protocol/kernel/error-handling.mdx (via /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))
  • content/docs/protocol/kernel/plugin-spec.mdx (via packageId (literal, a string literal in promoteDraftForPublish))
  • content/docs/ui/doc-pages.mdx (via packageId (literal, a string literal in promoteDraftForPublish))
  • content/docs/ui/setup-app.mdx (via packageId (literal, a string literal in promoteDraftForPublish))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))
  • content/docs/releases/v17/17-1.mdx (via /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))
  • content/docs/releases/v17/17-2.mdx (via /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))
  • content/docs/releases/v17/17-4.mdx (via /:type/:name/publish (route, bridged from symbol publishMetaItem — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 143 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 286eb9ef321c31e4344341033d32a193e7c607dd → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1aa06a4e484d8e528d4061effdeabcf1dd9a2758 — the merge of head fd2cd0764a5257fb53234d54107ade337e1ae71c into base 286eb9ef321c31e4344341033d32a193e7c607dd, 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 1aa06a4e484d8e528d4061effdeabcf1dd9a2758 && git checkout 1aa06a4e484d8e528d4061effdeabcf1dd9a2758
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 286eb9ef321c31e4344341033d32a193e7c607dd fd2cd0764a5257fb53234d54107ade337e1ae71c && git checkout -B drift-repro 286eb9ef321c31e4344341033d32a193e7c607dd && git merge --no-ff fd2cd0764a5257fb53234d54107ade337e1ae71c

node scripts/docs-audit/affected-docs.mjs --json 286eb9ef321c31e4344341033d32a193e7c607dd

⚠️ 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 286eb9ef321c31e4344341033d32a193e7c607dd → 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: CONTRACT_REVIEW_TIER
Head-sha: c313d36d74b7060818a5594723d1e296ec68fd43
Local-runs: none

Inputs: card #23495 (body, the triage grade 6170130346, the claim 6187661344, the os-dev-report 6189982386), PR #23654 (body, 11-file list, the net diff against main at the merge-base 23e689dc7), the check-runs on the head, and the repo's rule text at the head (AGENTS.md, ADR-0005, ADR-0048, ADR-0070, ADR-0087, scripts/pm/dispatch-gates.mjs, scripts/check-adr-0087-registration.mjs). Face: the file list holds packages/spec/src/api/protocol.zod.ts, a non-test file under the clause-② suspect glob packages/spec/src/**, so the schema face made the review owed and it is served at the contract-review tier.

Gate verdicts on this head at the time of writing: 27 success, 5 skipped, 7 pending, 0 failed. Check Changeset (the job that runs check-adr-0087-registration --base, check-changeset-no-major --base and check-empty-changeset) success; Type Check · source gates (holds the check:generated ledger step), Type Check · consumer gates, Type Check · debt ledger, Lint · ESLint, the three scoped lints, Lint · docs & code guards, Lint · CI, release & engine gates, Lint · PM dispatch-gates, Lint · repo & tooling gates, Governed Surface Queue Guard, Spec property liveness, Flag docs affected by code changes, Check Documentation Links, Build Core, Build Docs, Dogfood Regression Gate, the four PR-claim guards, Check PR Size and Auto Label success. Skipped by path filter: Console Pin Gate, Temporal Conformance, Dogfood Verify CLI, the dogfood shards, Packed-tarball smoke. Pending, named and not rendered here: Test Core (1/6) through Test Core (6/6) and Type Check · workspace. The dev's local suite runs, typechecks and three ablations are claims in the report, not gate verdicts; the contract below is judged on the diff.

① Derived judgments

The per-item publish, POST /api/v1/meta/TYPE/NAME/publish naming no package (an ABSENT packageId in process):

  1. Active rows bound to one package P, P's draft stored: promotes P's draft into P's active row. The draftKey === undefined branch that took the first draft row a package-agnostic findEnvironmentRow met is gone; the key is read from the new packagelessDraftAddress, every row read in full. Right — the draft save's address (SqlDriver org auto-scope makes SysMetadataRepository.listDrafts blind to env-wide drafts (organization_id IS NULL ∧ injected org = always empty); mode=draft saves drop package_id #11087 resolveWriteHead), the strict draft read's since metadata-protocol: a save naming a package beside an existing package-less row of the item mints a second row, and the read naming no package keeps serving the unbound one (S6, reachable through today's doors) #23291/fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489 (fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489 is an ancestor of the merge-base, so the card's ordering holds).
  2. Bound to P, the only draft at that address package-unbound: promoted INTO P through the repository's new promoteDraft({ adoptInto }); main minted a package-unbound active row beside P's that no read naming no package serves. Right — the save's orphan adoption, ADR-0070 TL;DR 1 (the package-less state is no authoring destination), metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165's defect class. Implied and not named in the changeset: the ADR-0086 D3 owner check (permissionSetOwnerBindingRefusal) is now given promoteInto, so an unbound permission_set draft whose body packageId names another owner is refused at publish instead of being promoted unbound. Right — it follows from the adoption and D3; no card.
  3. Bound to P, no draft at P and none unbound, other packages' drafts stored: 404 NO_DRAFT from the new packagelessPublishNoDraftRefusal, the message naming those drafts and ?package=, nothing written. main answered 200 and promoted the other package's draft into a second package's active row, after which every package-less save and delete of the item was 409. Right — the strict draft read's code and status in the same state, as the card reads ADR-0005; a declared narrowing.
  4. Bound to P, no drafts at all: 404 NO_DRAFT from promoteDraft, as before. Right.
  5. Bound to two or more packages: 409 RESOURCE_CONFLICT from the shared packagelessAddressAmbiguityRefusal with its new publish verb, nothing written; main promoted the first draft it met. Right — parity with the draft save (metadata-protocol: a package-less direct save of an item a package owns answers 200 and writes a package-less row the item read never serves; the draft save already resolves the owning package (#11087 / #22128) #23165 / PR fix(metadata-protocol)!: a save or delete that names no package addresses the one package's row, and a name two packages hold is refused 409 (#23165) #23290); a declared narrowing.
  6. Never published into a package: the package-unbound draft, else the one package's draft, else (two or more packages' drafts, none unbound) 409. The first two are what main answered when storage order happened to agree; the third was the storage-order toss publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907 removed from the batch door. Right; the 409 is a declared narrowing. Nit: the shared refusal's sentence says the item "belongs to N packages" read off draft rows alone — loose, not wrong.
  7. A present packageId: null in process still pins the unbound row; a named package is untouched. Right — the replaced protocol-publish-drafts-package-scope case keeps the absent-is-not-null guard as its own case.
  8. Ordering: the _lock gate is now given promoteInto (the package the active row is written under) and runs before the address refusals, as the save and the delete order theirs; the replaced org-scoped-write-refused pin asserts the publish naming no package of a two-package item is judged by the lock verdict the draft save naming none receives. Right.

The draft discard, DELETE /api/v1/meta/TYPE/NAME?state=draft naming no package:

  1. Bound to P: removes P's draft, else the unbound draft; with neither, 200 reset: false and a receipt naming the drafts that remain (main removed the other package's draft). request.packageId ?? draftAddress.key keeps a present null and an absent key on one path, as the published description already stated for the delete. Right; declared.
  2. Bound to two or more packages: 409, as the draft save is; main acted on whichever single package the draft rows named. Right — a declared narrowing.
  3. A widening the declaration does not name. main's discard read its ambiguity off the DRAFT rows: with drafts bound to two or more packages it answered 409 whatever the active binding (more than one candidate in packagelessAddress(…, 'draft')), and the published DeleteMetaItemRequestSchema.packageId description said so ("the item's rows at the deleted state … refused 409 RESOURCE_CONFLICT when they are bound to two or more"). On this head: bound to P with drafts [P, Q] removes P's draft (200); bound to P with [unbound, Q, R] removes the unbound draft; never published with [unbound, Q, R] likewise. The changeset's TO rule produces these answers, but its FROM block does not name the former 409, and the PR body's "Narrowing" section lists only the other direction. The behaviour is right — it is the card's direction E1 (the discard takes the draft save's address and is refused only where that address is ambiguous), and the draft-row reading it replaces is the wrong address the card names. The declaration is incomplete: judged in ② and escalated in ③.
  4. The active-row delete (state absent or active) and any delete naming ?package=: the old path, unchanged (draftAddress is computed only for a package-less draft discard). Right.

Receipt and public surface:

  1. The draft save naming no package: the receipt reads (env-wide, package=P, state=draft) when it inherits P, read off the same repo.headAt resolution its parent comes from; a save into the unbound row reads as before. Right; declared under Remedy. The storedParentVersion branch skips that read, so such a caller's receipt omits the package; the only caller is the stored-migration pass, which names packageId itself — nothing reachable differs.
  2. SysMetadataRepository.promoteDraft (a public export of @objectstack/metadata-protocol) gains the optional adoptInto; the result's packageId and the active row's binding are the adopting package for an adopted unbound draft; a bound draft keeps its binding whatever adoptInto says; omitted, unchanged (three pins). An additive option on a runtime class, minor-compatible. Right.
  3. packagelessAddressAmbiguityRefusal's verb union gains publish; the function is not re-exported from the package index. Internal. Right.
  4. @objectstack/spec: the PublishMetaItemRequestSchema.packageId and DeleteMetaItemRequestSchema.packageId descriptions are rewritten; no shape, default or export changes; content/docs/references/api/protocol.mdx carries the same two sentences (Flag docs affected by code changes and the check:generated ledger step success). Right — the old publish description ("an ABSENT key keeps the historical match any package resolution") would be false on this head, and the delete description now states the draft discard's address by delegating to the publish's.
  5. REST: the publish door's new 404 and 409 and the discard's 409 reach the wire through the existing status/code mapping; no route changes; the three real-stack pins assert status, code and the ?package= text. Right.
  6. The two replaced pins each carry a comment naming the removed behaviour and what the new case guards. Right.
  7. The card's wanted list: probe first on a real stack in both states, reported per door on main and on the fix, no half disproved; direction E1; the save receipt; pins for both states, both storage orders (stub engine; SQLite's real order on the rest harness), each door's address compared with the save's, and a negative control (the replaced match-any pin now asserts 409, plus the three reported ablations); order after fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489. All met.

② Semver level

  • @objectstack/metadata-protocol: minor, BREAKING with the bang, under the launch-window convention (check-changeset-no-major --base inside Check Changeset: success). Items 3, 5, 6 and 10 are accept-set narrowings; the changeset carries FROM, TO, Unchanged and Remedy; the adr-0087 marker not-required (no-migration-prescription) is right — nothing authorable is renamed or removed, no stored row is rewritten, the remedy is a request parameter, and the gate's prescription detector accepted the body (Check Changeset success; the dev's local reading [BREAKING+bang+clause-②-narrowing] agrees). Right.
  • @objectstack/spec: patch: description-only; no exported name or shape changes; the api-surface baselines record spec's exported names and are rightly untouched. Right.
  • packages/rest, packages/objectql: test-only; no changeset owed. Right.
  • Clause-②: no (narrowing) on the PR body and in the changeset: the grammar is AGENTS.md's (yes|no plus at most one arm; (narrowing) is BREAKING), the arm is the one the gate read, and the declaration is judged from the card CONTENT (dispatch-gates' clause-② note), which is a narrowing direction the triage graded as such. The line does not name item 11, the discard's 409 to 200 on two-package drafts, an accept-set change in a published description in the widening direction. The semver level is right either way (a widening also ships minor); no answer the diff gives is wrong; the TO rule states the behaviour that produces the corner. Whether that corner turns the line to yes is the seat's ruling at the clause-② enqueue gate, escalated below. Holds, with the corner named.

③ Boundary flags

Dev deviations:

  • D1, five files beyond the claim's surface: sys-metadata-repository.ts writes the promoted row's binding and holds the shared refusal; the two spec descriptions would otherwise be false; the mdx is regenerated; the real-stack pins the card asked for on a real kernel live in the rest harness; objectql's publish double must answer the find the address now reads. Each load-bearing; right. The packages/rest test file is cross-lane and test-only — escalated to the seat to file the declaration, as the dev asks.
  • D2, the Clause-②: no (narrowing) spelling: judged in ②. Escalated: the seat rules (a) whether item 11 turns the line to yes, and (b) whether the changeset's FROM block should name the discard's former 409 on two-package drafts before the queue — a text-only amendment if so.
  • D3, main not merged before the PR per the seat's overlap notice; merge-tree clean per the dev; GitHub reads the head mergeable: true. Right; the sibling rule with 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 (whichever lands second merges main) stands.
  • D4, model-free commit trailers: AGENTS.md's pair; not a contract matter. Right.

open_questions:

out_of_scope_findings (carrier none, noted not filed): the #9612 closure asymmetry for a publish naming no package (pre-existing, unchanged) and the discard receipt not naming the package of the draft it removed. The seat decides whether to file; neither blocks.

From this review: item 2's permission_set consequence is governed by ADR-0086 D3, no card. Item 6's "belongs to N packages" wording over draft rows alone is a nit, no card. The seven pending check-runs (Test Core 1 through 6, Type Check · workspace) must conclude green before the queue; this record judges the contract, not those runs.

Implemented-by: claude/issue-23495-draft-doors-one-address
Reviewed-by: session_01GP3comALHRcRBixS2TEnLF

VERDICT: PASS


Generated by Claude Code

…rowing) and names the discard's 409 corner it widens

Claude-Session: https://claude.ai/code/session_01GP3comALHRcRBixS2TEnLF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T10:54Z · ACCEPT (landing waits for every check on the head)

Head: fd2cd0764a5257fb53234d54107ade337e1ae71c. Its one commit over the reviewed head c313d36d74 touches only .changeset/23495-draft-doors-one-address.md: the seat's patch round, a prose face.

Item Reading
Shape Draft PR → main. The first line is Fixes #23495. A closing-keyword scan of the whole body finds one hit, line 1. Every other card number sits beside no closing verb.
Scope 11 files (+818 / −102), no content/docs/releases/ edit and no governed path. Five files are beyond the claim and declared in the body. Cross-lane surfaces are declared: domain:spec on #6017 (6190375788) for the two protocol.zod.ts descriptions, and domain:cli on #6024 (6190382712) for the packages/rest test file.
Clause-② yes (narrowing). Seat ruling on the contract review's ① item 11. On origin/main the draft discard naming no package reads its address off the rows at the deleted state (packagelessAddress(…, targetState)) and refuses 409 past one candidate. The published DeleteMetaItemRequestSchema.packageId text says so. On the head that request succeeds. The claim (6187661344, amended in place), the PR body (line 2 and the "Widening" section) and the changeset now agree.
Changeset, sentences read Clause-②: yes (narrowing). The FROM bullet "With drafts bound to two or more packages, DELETE …?state=draft was refused 409 RESOURCE_CONFLICT …". The TO bullet "An item never published into a package … refused 409 RESOURCE_CONFLICT when two or more packages hold drafts", read true against item 11. The spec line "the PublishMetaItemRequestSchema.packageId description now states …". BREAKING minor plus spec patch, with ADR-0087 marker not-required (no-migration-prescription). The dev's local reading of the three changeset gates is exit 0.
Refusal pins Envelope asserted, code + status: protocol pins toMatchObject({ code: 'RESOURCE_CONFLICT', status: 409 }), and { status: 404, code: 'NO_DRAFT' } on the publish. The REST real-stack pins assert statusCode plus body.code and that the stored rows are unchanged.
Contract review 6190278990, CONTRACT_REVIEW_TIER, PASS at c313d36d74. It still governs at fd2cd0764a, because the head moved by a changeset-only commit (a prose face).
Gates At fd2cd0764a: 7 success, 1 skipped, 17 running at this read. Landing needs every check green, not only the required five. The two Test Core cancels on c313d36d74 are superseded runs and are not acted on.

Deviations accepted:

  • the five files beyond the claim (each load-bearing, as the review's ③ D1 reads them);
  • main not merged before the PR, per the seat's overlap notice (GitHub reports mergeable: true);
  • the model-free trailers.

Disposition of what the dev and the review raised:


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 15, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 15, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 15, 2026
Merged via the queue into main with commit 57994c3 Oct 15, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23495-draft-doors-one-address branch October 15, 2026 11:53
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landed · domain:engine seat 1 · session_01GP3comALHRcRBixS2TEnLF · 2026-10-15T11:56Z

Probe Reading
Queue branch refs/heads/gh-readonly-queue/main/pr-23654-881eeff31… was at 57994c311f (11:35Z read). After the merge, git ls-remote finds no pr-23654 queue branch.
origin/main 57994c311f is on origin/main (git merge-base --is-ancestor exit 0). It is PR #23654's merge_commit_sha, merged at 11:53:48Z.
Cards closed Only #23495 (completed, 11:53:50Z). Issues closed since 11:50Z: #23495 alone. #23630, #23514, #23645, #23657 and #23658 stay open as the body intended.

Status: #23495 is closed and its pm:dispatched is removed in this stroke. #23630's Blocked-by: #23495 is now satisfied, and it is released on its own thread.


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/l tests tooling

Projects

None yet

2 participants