Repository navigation
fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) - #23489
Conversation
…kage-unbound row instead of minting a second row beside it A write that names a package, at either state, of an item whose only row at the address is the package-unbound one updates that row and stamps the binding (ADR-0070 TL;DR 1: the package-less state is a migration source). The publish of a package's draft reads its parent through the same write-address resolution, so it adopts the row too. Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
… package-less save writes, not the first row of any package Option (c) for the S6 shape: an item bound to exactly one package, with a package-unbound row stored beside the package's row, is read as the read naming that package reads it (ADR-0048 prefer-local), at the address packagelessAddress resolves for the save, the delete and the version token. The package-unbound row is kept, as ADR-0070 TL;DR 1 keeps it and as it stands in for every package with no row of its own. Reverts the adopt attempt of the previous commit, which removed that stand-in. Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
…row (patch) Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 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 6582f06eb34ffe84b8f31afbdfb107034fd146f1 && git checkout 6582f06eb34ffe84b8f31afbdfb107034fd146f1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1418de4c7e07bf15469ee8a91ebab19fc843d0b1 46282fff927bd0c6c3236009104aa5961e161675 && git checkout -B drift-repro 1418de4c7e07bf15469ee8a91ebab19fc843d0b1 && git merge --no-ff 46282fff927bd0c6c3236009104aa5961e161675
node scripts/docs-audit/affected-docs.mjs --json 1418de4c7e07bf15469ee8a91ebab19fc843d0b1
|
Contract reviewServed-tier: Inputs.
Check-runs on the head.
① Derived judgments
② Semver levelThe changeset declares
③ Boundary flagsopen_questions[0]: which option fits ADR-0070. (c) is upheld.
open_questions[1]: convert
open_questions[2]: the dispatch suggested (b), the measurement decided. The measurement governs. No action. out_of_scope_findings[0]: the package-less draft-save receipt omits the inherited package. A finding with no carrier cannot stay as it is. ESCALATED: carry it on the E1 card or on its own card. E1 (new; ESCALATED to the seat for a card; not a condition of this PR): the doors that act on a draft naming no package resolve three different addresses.
E3 (note only). In Ablation.
Neighbours on
Provenance. The PR body and the report say Tier face. The file list touches no published schema and no governed path, and the clause is Implemented-by: VERDICT: FAIL
Reviewed 2026-10-14T13:28Z, read-only: the card and every comment on it, the PR body, its file list and three-dot diff, Generated by Claude Code |
…overlay from the address the plain read serves getMetaItemLayered (`/layers`, `?layers=`, `/published` on both hosts) selects its overlay content row at packagelessReadAddress, the helper getMetaItem now shares, so `effective` is what the plain read returns. The lock stays at the request's address. Pins: the layered read in both storage orders, its stand-in control, and the draft corner (active rows bound to P, the only draft bound to Q: the strict read naming none answers 404 NO_DRAFT, the preview serves P's active row). Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
… read's 200 to 404 corner Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
…tside its catch, so an outage keeps the driver's error as cause Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review after the FAIL at Inputs.
Check-runs on the head (read at 14:45Z): 47 runs. 39 succeeded, 8 were skipped, none failed and none are in progress.
① Derived judgments
② Semver levelThe changeset declares
③ Boundary flagsThe two fixes from 6169548673. Fix 1 is discharged. In fix 2, (a), (b), (d) and the overstatement are discharged, and (c) is not (①.3, ①.5). Option (c) stays upheld. Nothing in this round reopens (a) or (b). Dev open question 0: should the relay have a
Dev open question 1: resolve
Carriers for 6169548673.
New in this review.
Implemented-by: VERDICT: FAIL
Generated by Claude Code |
…ds the row at the save's own address Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
…rs that follow the read Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Re-review, round 3. This follows the FAIL at
The PR body was rewritten again. Inputs.
The net diff. The code has not changed since
Check-runs.
① Derived judgments
② Semver levelThe changeset declares
③ Boundary flags
Implemented-by: VERDICT: PASS Landing conditions (not fixes):
Reviewed 2026-10-14T15:45Z, read-only. Read: the card and every comment on it; the PR body, its file list and its net diff; the source at the head; Generated by Claude Code |
…eaves every row unchanged; changeset: no metadata write door's own rule moves Claude-Session: https://claude.ai/code/session_01Deixz1D9pqfenZADBeqrAR Co-authored-by: Claude <noreply@anthropic.com>
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 38599104664 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Queue removal triage: the same tree re-queued once
What failed. Queue run 38599104664 ran on
Why it is not this PR's. The dev measured it in the foreground on a local merge of
Action: nothing was pushed, and the head stays Generated by Claude Code |
…issue-23429-metadata-family One conflict: packages/rest/src/meta-delete-drop-storage-other-rows.test.ts — main parameterised the seed (seedStoredRow(packageId)); S2 drops the retired organization_id key from it. Took main's shape without the key. Auto-merged files both sides touch (main = S1/S3/#23489, kept as merged): organization-ownership.ts keeps S2's template-lookup column guard (S2's own hunk); organization-ownership-plan.ts and -inventory.ts are main's (S1) byte-for-byte; protocol.ts keeps #23489's packagelessReadAddress beside S2's legacy-row filtering; rest-server.ts keeps S2's deletion of the form doors' organization read; generated translations and the engine-double ledger are regenerated after the merge. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WXLB6sUdwQZQ9NQS3mj3uZ
Fixes #23291
Clause-②: no
Option chosen: (c), the read naming no package resolves the address the save resolves
The triage (
6149653063) asked the lane to pick one of three options under ADR-0070 TL;DR 1 and say why. This PR takes (c).A by-name read that names no package now picks its stored content at the address
packagelessAddressresolves, through one helper,packagelessReadAddress(packages/metadata-protocol/src/protocol.ts). That is the one resolution the package-less save, the package-less delete and the read's ownversiontoken already use (#23165). For an item whose active rows are bound to exactly one packageP, the read serves what?package=Pserves:P's own row, else the package-unbound row (ADR-0048 prefer-local). Otherwise it serves any row, ADR-0048 §3.3's best-effort first match, as before.Two callers ask it:
getMetaItem: the item readGET /api/v1/meta/TYPE/NAME, plus the strict draft read and the draft preview. The draft reads take their address from the active rows, because the package-less draft save inheritsPfrom them (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).getMetaItemLayered:GET …/layers, the deprecated?layers=, andGET …/publishedon both hosts (RestServer, and the dispatcher's/publishedtwin andanswerMetaLayered). Soeffectiveis what the plain read returns, and/publishedserves the same document.The change is content only. The lock layer is still selected at the request's address, where the
_lockgate judges a package-less save, so the reads and the gate bind the same rows.Two consequences follow:
permissionorposition.objectsave's destructive-change check now reads at the save's own address (packageId: request.packageId, which a package-less publish save has already resolved toPabove it). It used to read naming no package, which judged the first stored row.[null, P]that moves its409 DESTRUCTIVE_CHANGEin both directions:P's row has is now refused, wheremainaccepted it and dropped the field unconfirmed;mainrefused it over a row it does not write.Qwith no row of its own is judged against the unbound stand-in it overlays, never againstP's row. This closes the review's third-package corner.forceremedy are unchanged.Why (c)
b776dcd1made a write naming a package adopt the item's package-unbound row inSysMetadataRepository.resolveWriteHead, which generalizes the 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 orphan-draft adoption. The full package suite went red inprotocol.org-scoped-write-refused.test.ts(2 cases, "a package-less env-wide copy stands in for every package with no copy of its own"). Under ADR-0048 prefer-local, the package-unbound row is the stand-in for every package that has no row of its own. Adopting it into one package silently takes the customization away from every other package that read through it. That is a stored-data change the card does not authorize, and ADR-0070 calls this state a read-side detail, not one package's property. Commit5e945626reverts it, together with the (c) fix.Psince 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. A package-less publish can still promote an unbound draft into it; that is metadata-protocol: the draft doors naming no package resolve three addresses — the save and read inherit the active binding, the discard reads the draft rows, the publish takes the first draft row a package-agnostic findOne returns #23495's state. It stays "a read-side rehydration detail": the stand-in for packages with no row, and the row served again onceP's row is deleted. For an item with no binding at all, the package-less row is still an authoring destination (step 1, and the save after step 5, write(env-wide, state=active)). That is ADR-0070's open work, not this card's. It also fixes a measured inconsistency inside the read itself. Onmain, the read naming no package served the unbound body with the package row'sversiontoken (HTTP reading below).[null, P]). This is the residue the dispatch named for (c). Naming no package, the item read, the layered and published reads, the draft reads, the list, the save, the delete and the version token now all addressP's row in this state. A read naming another package that has no row of its own still serves the unbound row, by design (the stand-in). The draft publish and discard naming no package are not part of this diff; the seat carries them on their own card. Whether to convert such pairs (a migration) is in Open questions.Measured first (mechanism assumptions)
1. The card's five steps reproduce on
main, at the protocol doors and over HTTP.At the protocol doors on
main90637274(row-keeping engine double, the suite's own):[null];[[null,'unbound'],['com.qa.lanebapp','pkg']];unbound, read naming the package →pkg;Saved app 's6' (env-wide, package=com.qa.lanebapp, state=active), read naming none →unbound, list →['third'];Deleted app 's6' (package=com.qa.lanebapp), and it still exists: its package-less row remains.Over HTTP on a real kernel (
pnpm dev:todo -- --fresh, signed-in admin, SQLite),PUT/GET/DELETE /api/v1/meta/app/NAMEwith and without?package=com.qa.lanebapp. Themainreading was taken with this PR's one-line fix ablated and the package rebuilt, with the dist preflight proving the ablated form was built:main(fix ablated)unbound,version= the package row's tokenpkg,_packageIdcom.qa.lanebapp, same token as?package=package=com.qa.lanebapp; GET servesunboundwith the package row's tokenpackage=com.qa.lanebapp; GET servesthird[["third","com.qa.lanebapp"]][["third","com.qa.lanebapp"]]unboundunbound, now the address of the next package-less save as well2. How a package-less row comes to exist beside a package's row today. Through today's doors, and not only from rows stored before ADR-0070. Step 1 (a package-less save of an item with no package binding writes the package-unbound row, #23165's control) followed by step 2 (a save naming the package inserts that package's row beside it) produces the pair on a fresh kernel, as the HTTP run shows. The other doors that write a
package_id-null row only rewrite or promote a row already unbound: the server-stated rewrites (sourceset), the promotion of an unbound draft, and the restore of an unbound row. A save of a code-shipped item with no stored row writes the unbound overlay. Rows stored before ADR-0070 are a second source of the same pair, and the read handles both the same way.Pins
packages/metadata-protocol/src/protocol.packageless-save-address.test.ts, a new[#23291]block on the suite's existing ledger-pinned engine double (no new double):P's row with the same token as?package=P. Step 4's save, pinned to that token, is accepted and the read and the list serve it. Step 5's package-less delete leaves the unbound row, which the read and the next package-less save both address. A last package-less delete removes it with the original "it no longer exists" receipt;P's;getMetaItemLayered({ type, name })reportsP's row asoverlay, and itseffectiveequals the plain read's item. The five-step pin checks it at step 3. A control: the layered read naming a package with no row of its own still reports the unbound row;?state=draftand the draft preview serveP's draft over an unbound draft, and the package-less draft save pinned to that token writes it;P, and the only draft bound to another packageQ. Naming no package, the strict draft read answers404 NO_DRAFT(envelopecode+status). That draft is read with?package=Q, and the draft preview servesP's active row;objectin[null, P]with the unbound row stored first:P's row has is refused409 DESTRUCTIVE_CHANGE, the rows are unchanged, and the same save is accepted withforce;P's row;Q, which has no row, is accepted when it drops a field onlyPhas, and refused when it drops one the unbound stand-in has;[null, P, Q], minted through the doors): package-less save and delete refused409 RESOURCE_CONFLICT, nothing changed, each named read serves its own row. AfterDELETE ?package=Q, the read and the package-less save addressPagain;Ablation (fix committed first;
node scripts/ablation-replace.mjs; each leg: anchor 1 → 0, restored blob = HEAD,git diff HEADempty), ate39d82defor the layered leg andb463e4befor the plain leg:contentPackageId = request.packageId): 2 red, the five steps and unbound-row-first order, both on the layeredoverlayassertion. 37 green, including the layered stand-in control.packageId: request.packageIdremoved), atc9e2de86: 1 red (the third-package pin), 41 green. The two package-less pins stay green under it because the plain read's resolution independently readsP.maindoes in this storage order.git statusclean)../protocol.jsfrom source, so no dist leg applies. The HTTPmainreading above used the same ablation with a rebuild:ablation-dist-preflightshowed the marker absent, then present again after restore, and the tree clean.Verification (head
8e3b9491;origin/mainmerged in at73c7dc4a, no merge since)pnpm --filter @objectstack/metadata-protocol test: 235 files passed, 3 skipped; 28400 tests passed, 19 skipped.typecheck: exit 0.@objectstack/restsuite at8e3b9491(it drives the/metasaves ofobject, the destructive 409 faces included): 287 files, 6060 passed, 328 skipped. Ate39d82de: every file that drives/published,/layersor?layers=(23 files), 862 passed.@objectstack/runtime, the dispatcher's/publishedtwin and its parity suites (http-dispatcher,meta-published-runtime-publish,meta-item-query-decision,meta-item-read-gate-parity): 401 passed. Nine dispatcher/meta files atb463e4be: 1208 passed.protocol.metadata-store-outage.test.ts, the layered read's 503 keeps the driver's error ascause) went red on the first layered change. The layered read's address is now resolved outside itscatch, and the pin is green.node scripts/pm/dispatch-gates.mjs --ranat8e3b9491reports "65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED". The one isnode scripts/check-plugin-teardown-shape.mjs --self-test, exit 3, which refuses on a shallow clone (its positive control is pinned to a commit), so it is NOT MEASURED locally and CI runs it.check:engine-double-contractpassed, since no new double was added.Changeset
.changeset/23291-packageless-read-serves-save-address.md:@objectstack/metadata-protocolpatch,Clause-②: no(upheld by the contract review).?layers=reads,/publishedon both hosts, and the draft reads.null.409 DESTRUCTIVE_CHANGErule now judges the row the save overwrites, so some saves it accepted are refused and some it refused are accepted.Open questions
[null, P]pair? (c) leaves both rows stored. A migration could fold an unbound row into the package's when no other package reads through it. That is a stored-data decision and an ADR-0070 "migration source" step, out of this card's scope. Recommendation: no conversion unless a measured reader still reaches the wrong row.(env-wide, state=draft)), unlike the direct save's receipt. This was observed, not changed here. The seat carries it, with the draft publish and discard addresses, on its own card.Acceptance notes
#23165(option B, 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) is untouched: the package-less save and delete still address the one package's row, and two packages are still refused.protocol.tswere merged in. fix(metadata-protocol)!: a delete with dropStorage drops an object's table only with its last stored row, refused 409 while another row remains #23464 had landed onmain, andmainwas merged before this PR opened (clean, delta vsmain= the three files above).Generated by Claude Code