Repository navigation
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
Conversation
…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>
📓 Docs Drift CheckThis PR changes 2 package(s): 14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 5 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
Contract reviewServed-tier: Inputs: card #23495 (body, the triage grade 6170130346, the claim 6187661344, the Gate verdicts on this head at the time of writing: 27 success, 5 skipped, 7 pending, 0 failed. ① Derived judgmentsThe per-item publish,
The draft discard,
Receipt and public surface:
② Semver level
③ Boundary flagsDev deviations:
From this review: item 2's Implemented-by: 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>
席位核对 ·
|
| 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);
mainnot merged before the PR, per the seat's overlap notice (GitHub reportsmergeable: true);- the model-free trailers.
Disposition of what the dev and the review raised:
filed #23657: OQ1, the draft save naming no package with no active binding and one package's draft. It carries the draft discard's receipt that names no package.filed #23658: OQ2,client.meta.publishItemcannot send?package=. It is client:meta.deleteItemcannot name a package — after #23165 a delete of a name two packages hold answers 409, and the SDK has no way to pass the?package=remedy #23293's twin.Acceptance notes: the runtime gate: full runtime-safe rule snapshot for the publish door — the expensive half split out of the object-gating card #9612 closure asymmetry. It predates this PR and is unchanged, already in the body.dropped — nit: the review's ① item 6 wording ("belongs to N packages" read off draft rows). Loose, not wrong.
Generated by Claude Code
|
Landed ·
Status: #23495 is closed and its Generated by Claude Code |
Fixes #23495
Clause-②: yes (narrowing)
What this does
A draft door that names no package on
/api/v1/meta/TYPE/NAMEnow 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,packagelessDraftAddressinpackages/metadata-protocol/src/protocol.ts. Where the draft save naming no package is refused, both are refused409 RESOURCE_CONFLICTwith the?package=remedy. The refusal comes from the same builder,packagelessAddressAmbiguityRefusal, which gains apublishverb. The draft save's receipt now names the package it inherits.PP's draft, else adopts the package-unbound draft intoPP's draft, else the package-unbound draftP's active rowP, no draft at that addressP's draft404 NO_DRAFT404 NO_DRAFT, naming the other packages' drafts and?package=200,reset: false; the receipt names the drafts that remain409 RESOURCE_CONFLICT409 RESOURCE_CONFLICT409 RESOURCE_CONFLICT409Governing 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
409when 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 realObjectQL, the realsys_metadata*objects, the realObjectStackProtocolImplementation, and the realRestServerroutes. It is the harness ofpackages/rest/src/meta-draft-head-package-inheritance.test.ts. Each door was driven on its own fresh boot, built through the doors.mainis23e689dc7; the fix isb411a4c54.State 1: active row bound to
P, drafts[package-unbound, P]. It is reachable through the doors in both arrangements: draft-first then package, orP's draft first. On SQLite, the store's find returns the package-unbound row first in both arrangements.mainGET ?state=draftP's draftP's draftPUT ?mode=draftP's draft; receipt(env-wide, state=draft)P's draft; receipt(env-wide, package=P, state=draft)POST …/publish[null, P]P's draft intoP's row; the package-unbound draft staysDELETE ?state=draftP's draftP's draftState 2: active row bound to
P, the only draft bound toQ. It is reachable throughPUT ?mode=draft&package=Q.mainGET ?state=draft404 NO_DRAFT404 NO_DRAFTPUT ?mode=draftP's draftP's draftPOST …/publish200, promotesQ's draft into aQactive row; the item then has two packages, and every package-less save or delete is refused409404 NO_DRAFT, namingQ's draft and?package=; nothing writtenDELETE ?state=draftQ's draft200 reset:false, "… its draft bound to package 'Q' remains"; nothing removedThe probe also covered the corners around the card's states:
P. Onmain, the publish promoted it into a package-unbound active row besideP's, which no read naming no package serves. This PR promotes it intoP's row, as the save adopts it. This state follows directly from state 1 onceP's draft is published.PandQ, one draft. The save is refused409. Onmain, the publish and the discard acted on that draft. This PR refuses all three with409.[package-unbound, Q]. Onmain, the discard removedQ's draft while the save wrote the package-unbound one. This PR acts on the package-unbound draft.[Q]only. It is unchanged: both doors act onQ'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 === undefinedbranch read a package-agnosticfindEnvironmentRow. H2 held: the discard resolved frompackagelessAddress(…, 'draft'). H3 held: the receipt read(env-wide, state=draft)with the package inherited.The decisions this PR takes, and why
P, that isP'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.Pis published intoP. 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 gainspromoteDraft({ 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.Pgives404 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 its200 reset:falsereceipt shape for an address that holds no row.?package=Qand publishes naming no package working:client.meta.publishItemcannot name a package. Two packages' drafts with no package-unbound one are refused409instead of a storage-order pick. This corner is outside the card's two states; see Open questions in the report.packageId: nullon the publish (in process only) still pins the package-unbound draft, asPublishMetaItemRequestSchemastates. Only the ABSENT key changed meaning.Narrowing: requests that answered 200 and no longer do
409 RESOURCE_CONFLICT. With no active row and drafts in two or more packages (no package-unbound draft), it is also409.Pwhose only draft is another package's, is now404 NO_DRAFT. It used to promote that draft.Both have their remedy in the request: name the package with
?package=. The changeset is BREAKING, shippedminorunder the launch-window convention, carries FROM, TO and the remedy, and its ADR-0087 marker isnot-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
Pwhile its drafts are bound to two or more packages, was refused409 RESOURCE_CONFLICT: the address was read off the draft rows, as the publishedDeleteMetaItemRequestSchema.packageIddescription stated. It now removesP'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:engineseat 1 ruled the lineClause-②: yeson it, atsession_01GP3comALHRcRBixS2TEnLF, and the changeset carries the same declaration and names the former409in 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:packages/metadata-protocol/src/sys-metadata-repository.ts, the same package:publishverb, since the existing builder is reused rather than a second dialect written;promoteDraft'sadoptInto, because the repository is what writes the promoted active row's binding.packages/spec/src/api/protocol.zod.ts: thePublishMetaItemRequestSchema.packageIddescription said an absent key "keeps the historical match any package resolution", which would now be false. TheDeleteMetaItemRequestSchema.packageIddescription now states the draft discard's address. These are description-only changes, andcontent/docs/references/api/protocol.mdxwas regenerated withcheck:generated --fix.packages/rest/src/meta-draft-head-package-inheritance.test.ts: the real-stack pins live here, beside 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 and finding(metadata-protocol): a second package-less draft save of a package-owned item is refused 409 METADATA_CONFLICT with no If-Match — the save door's head read and the repository's draft package inheritance read different rows #22128's.packages/objectql/src/protocol-publish-package-drafts.test.ts: an engine double that answered onlyfindOnenow also answersfind, which the draft address reads.Tests
Every run below is at
c313d36d7.packages/metadata-protocol/src/protocol.packageless-save-address.test.tsgains a new[#23495]describe with 8 cases over the stub engine:P;404 NO_DRAFT(envelopecode/status), the discard removes nothing, and?package=Qreaches the draft;P;409, nothing written;[package-unbound, Q],[Q]alone, and[Q, R]refused409.sys-metadata-repository.draft-package-inherit.test.tsgains 3adoptIntopins, andmeta-draft-head-package-inheritance.test.ts(rest, real stack) gains 3.protocol-publish-drafts-package-scope.test.ts"keeps the historical match-any resolution…" is now the409, plus a case pinning that an absent key is not a presentnull;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.@objectstack/metadata-protocolsuite ran in two halves: 236 files passed and 3 skipped; 20563 tests passed and 19 skipped.All green after the objectql double fix.
metadata-protocol,rest(withcheck:test-typecheck),objectql(withcheck:test-typecheck) andspec.tsc --listFilesin 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 toHEADand an emptygit diff HEAD. All went red, the expected direction:[#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.adoptIntoremoved in the repository: 2 red, the protocol adoption case and the repository pin.dist/, andablation-dist-preflightfound the marker indist/index.jsanddist/index.cjs;--absentheld, the tree was clean, and 15 of 15 passed.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, run atc313d36d7, derived 113 commands. All 113 ran locally, each exit code captured before any pipe.--ranreconciliation: 113 derived, 113 accounted for.109 exited 0. They include:
check:generated: all 14 artifacts up to date;check:docs,check:api-surfaceandcheck:engine-double-contract;check:durability-log-levelandcheck:doc-authoring;check-adr-0087-registration, which reads the changeset as[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription);check-changeset-no-majorandcheck-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-reactis 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.
mainmoved during the run. A localmerge-treeagainstc7dd48ab4is clean, with no conflict. Following the seat's instruction,mainis not merged.Acceptance notes
#23630 remains open: the promote doors' D9.9 ownership question sharespromoteDraftForPublishand is serial behind this PR. Its region is untouched.Pdoes not narrow toP's closure, while a direct save naming no package does: it resolvesPbefore its gates run (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). This asymmetry predates this PR and is unchanged here.client.meta.publishItemhas no package option. The remedy for the two narrowed requests,?package=, is reachable on the REST door and in process, but not through that SDK method. objectui'spublish()sends?package=; itspublishDraft()sends none.restoreArtifactRegistryView(metadata-protocol: deleting a runtime object hydrated at boot answers "it no longer exists" but keeps it registered and served until a restart — the delete heal re-registers the boot baseline before its unregister limb (Part of #23514) #23645, a sibling's) and the neighbouring regions of fix(metadata-protocol): an optimistic-concurrency conflict reports only what the caller may read #23628, fix(metadata-protocol)!: one sentence for every write over a sealed, package-held name; the inferred create remedy retires #23626, fix(metadata-protocol)!: an uninstall that would drop a table another stored row still serves is refused 409 before anything is removed #23504 and 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.