Repository navigation
metadata-protocol: the draft doors naming no package resolve three addresses — the save and read inherit the active binding, the discard reads the draft rows, the publish takes the first draft row a package-agnostic findOne returns #23495
Description
Filing gate: (a) a reachable defect a sibling change exposes.
reach:the draft doors on/api/v1/meta/TYPE/NAMEnaming no package: the draft save (PUT ?mode=draft), the draft read (?state=draft,?preview=draft), the draft discard, and the per-item publish.- Source: the contract review of PR fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489 (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's fix), comment
6169548673, item ③ E1, which escalates it to the seat for a card. It is read from source, not measured, so the first acceptance line below is the probe. - Reader who acts: triage, to grade and route (
packages/metadata-protocol,domain:engine).
Filed by domain:engine seat 1 · marchtian · session session_01Rerax7QTjKMPCUZxQUtPFR, at #23489's contract review. ⛔ Not a claim.
What happens (read from source at 52bfacaa04)
For one item, a request naming no package can reach three different draft addresses, depending on which door it uses:
- The draft save inherits the binding of the item's ACTIVE rows (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). After fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489, the strict draft read, the draft preview and the draft token follow the same address. - The draft discard resolves its address from the DRAFT rows (
packagelessAddress(…, 'draft')). - The per-item publish (
promoteDraftForPublish,draftKey) takes whichever draft row a package-agnosticfindOnereturns first. That is the storage-order coin toss publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907 removed frompublishPackageDrafts, still present on the per-item door.
Two states that split the doors:
[null-draft, P-draft], the active rows bound to P. The read, save, token and discard name P's draft. A publish naming no package can still promote the unbound draft.- The active rows bound to P, the only draft bound to Q. This is reachable through
?mode=draft&package=Qon P's item.- After fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489, the strict read naming no package answers 404
NO_DRAFT. - The publish and the discard naming no package act on Q's draft.
- ADR-0005 makes the read's 404 mean the same thing as the publish's empty slot.
- After fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489, the strict read naming no package answers 404
A second finding, from #23291's os-dev-report 6168967319 (out-of-scope finding 1), carried here: a package-less DRAFT save that inherits a package answers (env-wide, state=draft) and does not name the package. The direct save's receipt does name it.
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.
- 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 save or delete naming no package addresses the one bound package, and is refused 409
RESOURCE_CONFLICTwhen the address is ambiguous. - ADR-0005: the draft read's 404 and the publish's empty slot mean the same thing.
Wanted
-
The probe first, on a real kernel, in the two states above:
- the draft read naming no package;
- the publish (
POST /meta/TYPE/NAME/publish); - the discard naming no package.
Record which row each one reaches. Close any half the probe disproves, with the measurement.
-
The direction (review 6169548673 E1): the publish and the discard naming no package resolve their address as the draft save does, or are refused 409
RESOURCE_CONFLICTwith the?package=remedy when the address is ambiguous. -
The draft-save receipt names the inherited package, as the direct save's does.
-
Pins:
- both states;
- both storage orders;
- each door's address agreeing with the draft save's;
- a negative control that goes red when the fix is removed.
-
Order: after PR fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) #23489 lands, since it moves the draft read onto the save's address.
Dedupe
MCP search_issues on this repo, closed included: draft publish discard naming no package resolve different address package-less draft promoteDraftForPublish storage order → 37 hits.
- publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907 (closed) removed the storage-order pick from
publishPackageDrafts, not from the per-item publish. - 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 (closed) is the draft save's inheritance.
- metadata-protocol: a first draft pinned
If-None-Match: *on a sealed package-shipped item gets the create remedy ("name is taken"):sealedWritereads anullparent as a create, the spec as "no row of this lifecycle" #23406 (open) is the sealed-item first-draft remedy. - 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 is the active-read address.
None is this.
Dedupe words: draft discard publish naming no package address; promoteDraftForPublish draftKey findOne storage order per-item publish; draft save receipt inherited package env-wide.
Generated by Claude Code
Activity
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·priority:p1+target:v18·domain:engine·pm:queue· probe first · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T13:59ZRead fresh. It was read from source at PR #23489's review, not measured. For one item, a package-less request can reach three draft addresses (save/read follow the active binding; discard follows the draft rows; per-item publish takes a package-agnostic
findOne's first row).target:label.domain:engine(packages/metadata-protocol), with 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's family.objectstack-fleet commentedon Oct 15, 2026
Claim: PM loop round 1 · 2026-10-15T08:16Z
Session:
session_01GP3comALHRcRBixS2TEnLFAccount:
sunhaolin(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23495-draft-doors-one-addressWorktree:
objectstack-issue-23495Domain:
domain:engineSeat:
domain:engine#1(seat post #6367)File surface:
packages/metadata-protocol/src/protocol.ts, the draft doors' address resolution only: thedraftKeyread inpromoteDraftForPublish, the draft-side address indiscardDraftInItsScope/packagelessAddress, and the draft-save receipt line insaveMetaItem. Plus new tests (a real-kernel probe and pins) and one changeset.changeset/23495-*.md. Stop on a breach and explain it in the report.Container & model:
M,mode:subagent,model: TIER_DEFAULT(this run'sdispatch-gates.mjs --tier: "no path-derived mandate"; a probe-first card with a door direction to judge)Clause-②: yes
Responsibility: the risk is produced by
metadata-protocol's own draft doors (the per-item publish's package-agnostic draft read and the discard's draft-row address) | no platform path covers it: the draft save's inheritance (resolveWriteHead) reaches the save, read and token only | an author on/api/v1/meta/TYPE/NAMEdraft doors naming no package over a package-bound item reaches it, and the second state is reachable through?mode=draft&package=Q. Use today is unmeasured, so the card's probe comes first.Thread-read: 6170130346
Serial constraints cleared: open PRs on the same file, none in this region: #23628 (#23586, the
updateData/deleteDataconflict region), #23626 (#23577, the sealed-write region andsaveMetaItem's tenant-authored-write refusal) and #23504 (#23473, the uninstall pre-flight). Sibling in this batch: #23514 (this file's object-ownership region, written in parallel; whichever lands second mergesmain). Queue card #23630 member 1 (the promote doors' D9.9 question) sharespromoteDraftForPublish. Fold refused: its defect is ownership, not address. It is serial behind this card.Amended 2026-10-15T10:5xZ by this seat (same session):
Clause-②: no→yes. The contract review 6190278990 on PR #23654 (① item 11) found a widening: a draft discard naming no package, of an item bound to one package whose drafts are bound to two or more, answered409onmain(as the publishedDeleteMetaItemRequestSchema.packageIddescription stated) and removes that package's draft on the head. The narrowings stand; the changeset and the PR body carryClause-②: yes (narrowing).Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
os-dev-report
{ "issue": 23495, "status": "done", "branch": "claude/issue-23495-draft-doors-one-address", "pr": "https://github.com/objectstack-ai/objectstack/pull/23654", "session": "session_01GP3comALHRcRBixS2TEnLF (subagent: the dispatching PM session's id; the fleet-write relay derived the same id from the container)", "premise_still_valid": true, "summary": "The probe found a split in both card states on a real stack (better-sqlite3, the real ObjectQL, protocol and RestServer routes, via the harness of packages/rest/src/meta-draft-head-package-inheritance.test.ts), so no half closes with the reading. At main 23e689dc7, in state 1 (active row bound to P, drafts [package-unbound, P]), the publish naming no package promoted the package-unbound draft into a package-unbound active row; the save, the read and the discard named P's draft. In state 2 (the only draft bound to Q), the read answered 404 NO_DRAFT and the save wrote P's draft, but the publish promoted Q's draft into a Q active row and the discard removed it. H1, H2 and H3 held. The fix routes both doors through one helper, packagelessDraftAddress in protocol.ts: the draft save's address read from the active rows. For an item bound to one package it is P's draft, else the package-unbound draft, which is published INTO P through the new repository option promoteDraft({adoptInto}), as the save adopts it. With no draft at that address, the publish answers 404 NO_DRAFT naming the remaining drafts and ?package=, and the discard answers 200 reset:false naming them. Two or more packages are refused 409 RESOURCE_CONFLICT by the shared builder, which gains a publish verb. An item never published into a package takes the package-unbound draft, else the one package's draft, else 409. The draft save receipt names the inherited package. The spec descriptions for PublishMetaItemRequestSchema.packageId and DeleteMetaItemRequestSchema.packageId were corrected and the docs regenerated. This is a narrowing, so the changeset is BREAKING minor with Clause-②: no (narrowing) and ADR-0087 not-required (no-migration-prescription), and it owes the seat a contract-tier review before the queue.", "tests": "All runs are at HEAD c313d36d7, under scripts/pm/os-verify-lock.sh, and each verdict was read from the VERDICT line. (1) The full metadata-protocol suite ran in two halves of 120 and 119 files: 'Test Files 117 passed | 3 skipped (120) / Tests 18223 passed | 19 skipped' and 'Test Files 119 passed (119) / Tests 2340 passed (2340)', both command-exit 0. (2) Consumer files that drive a draft door: objectql 20 files (the first run had 3 red in protocol-publish-package-drafts.test.ts, because its engine double lacked find, which the draft address reads; the double was widened, and the rerun read '1 passed, 17 tests'); rest 29 files, 1481 tests; runtime 13 files, 1115 tests; all command-exit 0. (3) Typecheck exited 0 for metadata-protocol, rest (+check:test-typecheck), objectql (+check:test-typecheck) and spec; tsc --listFiles in metadata-protocol counted 4/4 of the edited test files. (4) New pins: 8 cases in protocol.packageless-save-address.test.ts (state 1 in both storage orders, state 2, the package-unbound draft adopted into P, two packages 409, and the no-active-row corners); 3 adoptInto pins in sys-metadata-repository.draft-package-inherit.test.ts; 3 real-stack REST pins. Two pins of the removed first-row pick were replaced, not respelled: protocol-publish-drafts-package-scope.test.ts (match-any is now 409, plus a pin that an absent key is not null) and protocol.org-scoped-write-refused.test.ts (the draft-row lock is now parity with the draft save's lock). (5) Ablations went through scripts/ablation-replace.mjs, which proved anchor 1->0 on disk and that the restored blob equals HEAD with an empty git diff HEAD. All were red, the expected direction: A1, the draft address ignoring the active rows: 6 red / 350 green, with the corner cases for an item never published into a package staying green as predicted. A2, adoptInto removed in the repository: 2 red. A3, the receipt line deleted: 2 red. A1 on the dist-resolved REST suite: built, ablation-dist-preflight found the marker in dist/index.js and dist/index.cjs, 3 of 15 red; restore leg rebuilt, preflight --absent held, the tree was clean against HEAD, and 15 of 15 passed. The pristine marker reading before the mutation was 0 hits in src and dist.", "gates": "Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at c313d36d7, no paths: 113 commands, all run locally, each exit captured before any pipe. --ran with exit codes reads: '113 derived, 110 run, 3 NOT-MEASURED, 0 UNRUN'. 109 exited 0, including check:generated ('All 14 generated artifacts are up to date'), check:docs, check:api-surface, check:engine-double-contract (1009 pinned), check:durability-log-level, check:doc-authoring, check:cross-package-test-inputs, check:test-source-alias, check-adr-0087-registration ('[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)'), check-changeset-no-major and check-empty-changeset. NOT MEASURED (prerequisite refusals in this container, not findings): check-plugin-teardown-shape --self-test (exit 3, shallow clone, the pinned positive control is unreachable); spec check:skill-examples (exit 3, client-react not built); check:dual-build-cjs-loads (exit 3, the whole repo not built); check-engine-split-ratio --days 90 (exit 2, shallow clone refuses a windowed metric). CI runs all four. CI on PR #23654 at c313d36d7 when this report was written: 24 check-runs; 'No other open PR may claim the same issue' success, 'Packed-tarball smoke (opt-in)' skipped, and the rest in_progress, including Check Changeset, the Type Check lanes, the Lint lanes and Governed Surface Queue Guard. I did not wait for them. main moved to c7dd48ab4; a local merge-tree is clean, and main was not merged, per the seat's notice.", "mcp_calls": "0 — no MCP GitHub tool used (reads went through gh api REST GETs)", "api_writes": "2 relay strokes, each POST /repos/objectstack-ai/objectstack/dispatches with event fleet-write, executed as objectstack-fleet[bot]. Stroke 1 (run 38720400548) made 2 writes: POST /repos/objectstack-ai/objectstack/pulls (draft #23654) and POST /repos/objectstack-ai/objectstack/issues/23654/assignees [sunhaolin]; the read-back confirmed the body byte-identical and the assignee present. Stroke 2 is this report: POST /repos/objectstack-ai/objectstack/issues/23495/comments. git push is not REST. 0 label writes: the dispatch named no label and skip-changeset does not apply.", "files_changed": [ ".changeset/23495-draft-doors-one-address.md", "content/docs/references/api/protocol.mdx (regenerated by check:generated --fix)", "packages/metadata-protocol/src/protocol.ts", "packages/metadata-protocol/src/sys-metadata-repository.ts", "packages/metadata-protocol/src/protocol.packageless-save-address.test.ts", "packages/metadata-protocol/src/protocol-publish-drafts-package-scope.test.ts", "packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts", "packages/metadata-protocol/src/sys-metadata-repository.draft-package-inherit.test.ts", "packages/objectql/src/protocol-publish-package-drafts.test.ts", "packages/rest/src/meta-draft-head-package-inheritance.test.ts", "packages/spec/src/api/protocol.zod.ts" ], "line_budget": "919 changed lines (+817 / -102) across 11 files, under the 3000 human-merge threshold (dispatch-gates reading); no governed surface", "deviations": [ "File surface extended beyond the claim, declared in the PR body. Five files: sys-metadata-repository.ts (the shared refusal's publish verb and promoteDraft adoptInto, the producer of the promoted row's binding); spec protocol.zod.ts (two .describe texts that would otherwise be false); the regenerated protocol.mdx; packages/rest/src/meta-draft-head-package-inheritance.test.ts (the real-stack pins, cross-lane: the seat files the declaration); packages/objectql/src/protocol-publish-package-drafts.test.ts (an engine double widened to answer find).", "PR body line reads 'Clause-②: no (narrowing)': the claim's value no, plus the arm the dispatch requires for a narrowing; the changeset carries the same line.", "main was not merged before opening the PR (AGENTS.md §10 asks for one pre-PR pull), per the seat's overlap notice; the merge-tree against c7dd48ab4 is clean.", "The commit trailers use the model-free pair (AGENTS.md). The harness reminder asked for a model-named Co-Authored-By, which I did not use." ], "open_questions": [ { "question": "An item never published into a package (no active row): the draft save naming no package writes the package-unbound draft. With only Q's draft stored, that save mints a SECOND draft beside Q's (measured: drafts [SAVED@null, Q-draft@Q]), while this PR's publish and discard act on Q's draft and the read is best-effort. Should the draft save naming no package, with no active binding and exactly one package's draft, inherit that draft's package?", "options": [ "A: inherit it in resolveWriteHead, so the save, read, publish and discard all name Q's draft (a save-door change in sys-metadata-repository.ts; ADR-0070 TL;DR 1 direction; small)", "B: leave the save as #11087 states it and keep this PR's rule (unbound draft first, else the one package's draft) — today's behaviour for the save; the doors disagree only in this corner", "C: refuse the publish and discard naming no package for an item with no active binding unless the package-unbound draft exists (strict save address; breaks clients that draft with ?package=Q and publish via client.meta.publishItem, which cannot name a package)" ], "recommendation": "A, on its own card. Real need: measured reachable through the doors. Long-term: it removes the last package-less authoring destination among the draft doors (ADR-0070 TL;DR 1). AI-mistake axis: one address for every draft door. Scope: it is a one-branch change at the producer. C narrows a flow the SDK cannot remedy. This PR takes B so as not to change the save door outside the claim." }, { "question": "client.meta.publishItem has no package option, so the remedy the two narrowed answers name (?package=) is not reachable through that SDK method (REST and in-process are). Add one?", "options": [ "A: add opts.packageId to publishItem, sent as ?package=, mirroring saveItem/deleteItem (a widening in @objectstack/client)", "B: leave it; callers use REST for the narrowed cases" ], "recommendation": "A: the save and delete methods already carry the option, and a refusal whose remedy the SDK cannot send is a trap for an agent author." } ], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted in PR #23654 Acceptance notes, not filed — the runtime authoring gate's #9612 package closure narrows only on a caller-stated package, so a publish naming no package that resolves P does not narrow to P's closure while the direct save naming no package does (pre-existing, unchanged).", "carrier: none (承接者:无) · noted, not filed — the draft discard receipt 'Draft discarded — TYPE/NAME' does not name the package, unlike the direct delete's; the card asked only for the save receipt.", "carrier: none (承接者:无) · noted, not filed — client.meta.publishItem has no package option (see open_questions[1]); dedupe words: publishItem package option, SDK publish ?package=, per-item publish client packageId." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 15, 2026
ACCEPT → PR #23654 comment 6190398724 ·
domain:engineseat 1 · 2026-10-15T10:55Z · contract review 6190278990 (PASS) ·Clause-②amendedno→yes· filed #23657 and #23658 · landing waits for every check onfd2cd0764a.Generated by Claude Code