Skip to content

fix(metadata-protocol): the read naming no package serves the row the package-less save writes, beside a package-unbound row (S6) - #23489

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-23291-package-address
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-23291-package-address

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

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 packagelessAddress resolves, 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 own version token already use (#23165). For an item whose active rows are bound to exactly one package P, the read serves what ?package=P serves: 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:

The change is content only. The lock layer is still selected at the request's address, where the _lock gate judges a package-less save, so the reads and the gate bind the same rows.

Two consequences follow:

  • In-process readers of the item naming no package follow the read: the data doors' object reads (import reference targets, export and template fields), seed apply's object lookup, and the security catalog echo of a permission or position.
  • The object save's destructive-change check now reads at the save's own address (packageId: request.packageId, which a package-less publish save has already resolved to P above it). It used to read naming no package, which judged the first stored row.
    • In [null, P] that moves its 409 DESTRUCTIVE_CHANGE in both directions:
      • a package-less save dropping a field only P's row has is now refused, where main accepted it and dropped the field unconfirmed;
      • one dropping a field only the unbound row has is now accepted, where main refused it over a row it does not write.
    • A save naming a third package Q with no row of its own is judged against the unbound stand-in it overlays, never against P's row. This closes the review's third-package corner.
    • The rule and its force remedy are unchanged.

Why (c)

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 main 90637274 (row-keeping engine double, the suite's own):

  1. rows [null];
  2. rows [[null,'unbound'],['com.qa.lanebapp','pkg']];
  3. read naming none → unbound, read naming the package → pkg;
  4. package-less save → Saved app 's6' (env-wide, package=com.qa.lanebapp, state=active), read naming none → unbound, list → ['third'];
  5. package-less delete → 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/NAME with and without ?package=com.qa.lanebapp. The main reading was taken with this PR's one-line fix ablated and the package rebuilt, with the dist preflight proving the ablated form was built:

step main (fix ablated) this PR
3 · GET, no package label unbound, version = the package row's token label pkg, _packageId com.qa.lanebapp, same token as ?package=
4 · PUT, no package, then GET, no package receipt package=com.qa.lanebapp; GET serves unbound with the package row's token receipt package=com.qa.lanebapp; GET serves third
4 · list [["third","com.qa.lanebapp"]] [["third","com.qa.lanebapp"]]
5 · DELETE, no package, then GET, no package receipt "…its package-less row remains"; GET unbound the same receipt; GET unbound, now the address of the next package-less save as well

2. 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 (source set), 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):

  • the card's five steps, each read asserted against its save's address: after step 2 the read naming none serves 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;
  • storage order: the unbound row stored first and second, both served as P's;
  • the layered read, in both storage orders: getMetaItemLayered({ type, name }) reports P's row as overlay, and its effective equals 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;
  • the draft reads: ?state=draft and the draft preview serve P's draft over an unbound draft, and the package-less draft save pinned to that token writes it;
  • the draft corner: active rows bound to P, and the only draft bound to another package Q. Naming no package, the strict draft read answers 404 NO_DRAFT (envelope code + status). That draft is read with ?package=Q, and the draft preview serves P's active row;
  • the destructive-change check, on an object in [null, P] with the unbound row stored first:
    • a package-less save dropping a field only P's row has is refused 409 DESTRUCTIVE_CHANGE, the rows are unchanged, and the same save is accepted with force;
    • a package-less save dropping a field only the unbound row has is accepted and writes P's row;
    • a save naming Q, which has no row, is accepted when it drops a field only P has, and refused when it drops one the unbound stand-in has;
  • edge, another package's row too ([null, P, Q], minted through the doors): package-less save and delete refused 409 RESOURCE_CONFLICT, nothing changed, each named read serves its own row. After DELETE ?package=Q, the read and the package-less save address P again;
  • control, the stand-in: a read naming another package with no row still serves the unbound row (ADR-0048 prefer-local).

Ablation (fix committed first; node scripts/ablation-replace.mjs; each leg: anchor 1 → 0, restored blob = HEAD, git diff HEAD empty), at e39d82de for the layered leg and b463e4be for the plain leg:

  • layered line (contentPackageId = request.packageId): 2 red, the five steps and unbound-row-first order, both on the layered overlay assertion. 37 green, including the layered stand-in control.
  • plain-read line: 5 red: five steps, unbound-row-first order, the draft corner, draft reads, edge. 34 green.
  • destructive-check line (packageId: request.packageId removed), at c9e2de86: 1 red (the third-package pin), 41 green. The two package-less pins stay green under it because the plain read's resolution independently reads P.
    • With both that line and the plain-read line removed: the two package-less pins go red. The third-package pin passes there, as main does in this storage order.
    • Restored byte-identical to HEAD (hash equal, git status clean).
  • The first round's ablation (before the layered fix): 4 red, 2 controls green. The suite imports ./protocol.js from source, so no dist leg applies. The HTTP main reading above used the same ablation with a rebuild: ablation-dist-preflight showed the marker absent, then present again after restore, and the tree clean.

Verification (head 8e3b9491; origin/main merged in at 73c7dc4a, no merge since)

  • pnpm --filter @objectstack/metadata-protocol test: 235 files passed, 3 skipped; 28400 tests passed, 19 skipped. typecheck: exit 0.
  • The full @objectstack/rest suite at 8e3b9491 (it drives the /meta saves of object, the destructive 409 faces included): 287 files, 6060 passed, 328 skipped. At e39d82de: every file that drives /published, /layers or ?layers= (23 files), 862 passed.
  • @objectstack/runtime, the dispatcher's /published twin 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 at b463e4be: 1208 passed.
  • An outage pin (protocol.metadata-store-outage.test.ts, the layered read's 503 keeps the driver's error as cause) went red on the first layered change. The layered read's address is now resolved outside its catch, and the pin is green.
  • Gates: node scripts/pm/dispatch-gates.mjs --ran at 8e3b9491 reports "65 derived famil(ies) accounted for — 64 run, 1 NOT-MEASURED". The one is node 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-contract passed, since no new double was added.

Changeset

.changeset/23291-packageless-read-serves-save-address.md: @objectstack/metadata-protocol patch, Clause-②: no (upheld by the contract review).

  • It names the doors whose served row changes: the item read, the layered and ?layers= reads, /published on both hosts, and the draft reads.
  • It states the draft read's corner, which moves from 200 to 404: the strict draft read naming no package. That is the absence answer at the draft save's address, whose token was already null.
  • It states the destructive-change check's move: the same 409 DESTRUCTIVE_CHANGE rule now judges the row the save overwrites, so some saves it accepted are refused and some it refused are accepted.
  • It names the in-process readers that follow the read.
  • No export, key, schema or error code moves, and no stored row is rewritten.

Open questions

  • Convert the [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.
  • The draft-save receipt names no package when the draft inherits one ((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


Generated by Claude Code

…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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), getMetaItemLayered (symbol, a method of class ObjectStackProtocolImplementation), saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/drivers.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/deployment/validating-metadata.mdx (via 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 getPublished (sdk, the bare tail of client method meta.getPublished, bound to GET /api/v1/meta/:type/:name/published; the bare tail of client method meta.getPublished, bound to GET /meta/:type/:name/published))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (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))

⛔ 3 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))
  • content/docs/releases/v17/17-3.mdx (via /:type/:name/published (route, bridged from symbol getMetaItemLayered — 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1418de4c7e07bf15469ee8a91ebab19fc843d0b1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6582f06eb34ffe84b8f31afbdfb107034fd146f1 — the merge of head 46282fff927bd0c6c3236009104aa5961e161675 into base 1418de4c7e07bf15469ee8a91ebab19fc843d0b1, 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 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

⚠️ 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 1418de4c7e07bf15469ee8a91ebab19fc843d0b1 → 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
Served on opus by the maintainer's order 「后续契约复审用 opus就够了」 (seat note 6166448512 on #6367).
Head-sha: 52bfacaa040442d3aee47fe35df31efea3edd3a4
Local-runs: none

Inputs.

Check-runs on the head.

  • First read at 13:17Z: 39 runs. Test Core (1/6) to (6/6) were in progress, so not yet reported.
  • Second read at 13:28Z: 40 runs. 34 success, 6 skipped, 0 failure, 0 in progress.
    • Skipped (path-filtered or opt-in): Build Docs, Console Pin Gate, Dogfood Regression Gate (matrix/3), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Temporal Conformance (live PG + MySQL).
    • Required contexts, all success: Lint & Repo Gates, TypeScript Type Check, Test Core and all six shards, Build Core, Governed Surface Queue Guard.
  • Green answers only what the suite pins. It cannot reach the gap in ①.9, because no pin covers it.

① Derived judgments

  1. Active read naming no package. RIGHT.

    • Covers GET /api/v1/meta/TYPE/NAME on both hosts, and getMetaItemCached, which delegates to it.
    • New line: contentPackageId = request.packageId || packagelessAddress(…).packageId.
    • When the item's active rows are bound to exactly one package P, findServedOverlayRow reads at P's address: P's row first, else the package-unbound row (ADR-0048 prefer-local). Otherwise it serves any row, as before.
    • The accept set is unchanged. P's binding is read off an active P row, so the read still finds a row and answers 200 wherever it did before.
  2. Agreement with the read's new address. RIGHT.

  3. Strict draft read (?state=draft) and draft preview (previewDrafts). RIGHT in the pinned states; one corner is unpinned and unstated.

    • Both take their address from the ACTIVE rows, as the draft save inherits it (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 draft token (headAt(draft, null)) resolves the same address, orphan adoption included.
    • In [null-draft, P-draft] they now serve P's draft, with P's draft token. This is pinned.
    • The corner: the active rows are bound to P and the only draft is bound to another package Q. It is reachable today: a ?mode=draft&package=Q save of P's item.
      • Before: 200 with Q's draft and version: null.
      • Now: the strict read answers 404 NO_DRAFT, and the preview falls back to P's active row.
    • The 404 is the consistent answer at the draft save's address; the token there was already null. But the changeset denies this change (see ② and fix 2), and the publish and discard doors naming no package now answer differently (③ E1).
  4. Two-package edge [null, P, Q]. RIGHT.

    • packagelessAddress is ambiguous, so no package is used and the read takes the first match: ADR-0048 §3.3's best effort.
    • The save and the delete naming none are refused 409 RESOURCE_CONFLICT, and nothing changes. This is pinned.
    • After DELETE ?package=Q, the read and the package-less save address P again. This is pinned.
    • No save address exists in this state, so there is nothing for the read to disagree with. The leftover token mismatch is noted in ③ E3.
  5. Delete naming no package. RIGHT.

    • This diff does not change it. In [null, P] it removes P's row, and its receipt names the remaining package-less row.
    • The read then serves the unbound row, and the next package-less save writes it. This is pinned (step 5).
    • So the delete now removes exactly what the read served. On main it did not.
  6. The stand-in. RIGHT. A read naming a package that has no row of its own still serves the unbound row. This is pinned as a control.

  7. Surfaces that do not change. RIGHT.

    • No export, type, schema, error code, request key or stored row moves, and there is no new refusal.
    • The pins sit on the suite's existing ledger-pinned engine double.
    • Cost: a package-less active read now runs packagelessAddress twice, once for the content and once for the token, and each run is a store find. This is not a contract issue; resolving it once is a cheap follow-up.
  8. Composition with feat(spec): the app item read answers by package id — 403 / 404 by the package key, per ADR-0048 §3.1 #23468 and 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. RIGHT.

  9. The layered read was not moved. WRONG.

    • findServedOverlayRow is documented as "the ONE content resolution getMetaItem (both its draft-preview arm and its row read) and getMetaItemLayered call".
    • This diff moves two of those three callers onto the save's address. The third, getMetaItemLayered, still calls it with request.packageId only.
    • getMetaItemLayered serves:
      • GET /meta/TYPE/NAME/layers;
      • the deprecated ?layers= flag;
      • GET /meta/TYPE/NAME/published, on RestServer (the /published handler passes { type, name } only) and on the dispatcher (the /published twin, and answerMetaLayered).
    • In the card's state, with the unbound row stored first, these doors still serve the unbound row as overlay, as effective and as the published document. The plain read now serves P's row.
    • On main both doors served the same first match. This diff opens a split between doors whose contract says they agree:
      • effective is documented as "what getMetaItem would return";
      • /published serves "the same representation the plain read serves".
    • It also makes the PR body's "No door reaches different rows through it any more" false.
    • A layered caller naming no package now sees as overlay a body that a delete naming none does not remove.
    • No pin covers this, so neither the ablation nor CI could see it. See fix 1.

② Semver level

The changeset declares @objectstack/metadata-protocol: patch with Clause-②: no. The PR body carries Clause-②: no on line 2. Check Changeset succeeded.

  • This is a defect fix, not a narrowing and not a widening.
    • The same request is still accepted and still answers 200 (①.1).
    • Nothing new is accepted, and no key, export or schema moves.
    • What changes is which stored row the answer carries. The old row was the defect: no save, delete, list slot, nor the read's own version named that body.
    • So Clause-②: no with no arm is right, and so is the triage's grade. patch is the level AGENTS.md sets for a bug fix in a released package. No BREAKING banner and no ADR-0087 marker are owed.
  • The draft corner does not change the grade. The 404 NO_DRAFT in ①.3 is the absence answer at the address the draft save writes, where the token was already null. It is not a refusal of the request, so the level and the clause stand.
  • The changeset text does not match what the diff publishes.
    • It is silent on ①.3's corner, where a request that answered 200 with another package's draft now answers 404.
    • Its sentence "no request is refused that was accepted before" holds only if a 404 absence answer is not counted. A consumer will read it as "nothing answers differently".
    • After fix 1 it must also name the layered and published reads.
    • See fix 2.

③ Boundary flags

open_questions[0]: which option fits ADR-0070. (c) is upheld.

  • (b) adopt. The reverted attempt widened resolveWriteHead's orphan adoption to every write that names a package, at either state. protocol.org-scoped-write-refused.test.ts §(c), "a package-less env-wide copy stands in for every package with no copy of its own" (line 840), pins the ADR-0048 stand-in that adoption would delete. Adopting would also rewrite stored rows, which the card's existing-rows line excludes. Rejected on evidence.
  • (a) refuse. It would refuse the card's step 2, which is exactly the package-first move ADR-0070 wants authors to make. Its only remedy is to delete the unbound row, which removes the stand-in for every other package. Rightly rejected.
  • (c) against ADR-0048 §3.3. §3.3 makes a context-free read best effort and does not fix which match it takes. (c) picks a better first match inside that latitude, and keeps the plain first match exactly where no package-less write address exists ([null, P, Q]).
  • (c) against ADR-0070 TL;DR 1. (c) keeps the unbound row as "a read-side rehydration detail" and as the stand-in, and serves it again once P's row is gone.
  • One overstatement in the PR body (non-blocking). It says "the package-less state is no longer an authoring destination". It still is, for an item with no binding: step 1 and step 5's next save both write (env-wide, state=active), and this PR's own pins assert it. That is ADR-0070 D1/D5's open work, not this card's. Correct the sentence.

open_questions[1]: convert [null, P] pairs.

  • Answer A for this card: leave both rows. Converting them is an ADR-0070 D5 migration decision, not this lane's.
  • The dev's own condition, "unless a measured reader still reaches the wrong row", is met by ①.9. The remedy for that is fix 1, not a migration.

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.

  • The draft save inherits the active rows' binding.
  • The draft discard resolves from the draft rows (packagelessAddress(…, 'draft')).
  • The publish (promoteDraftForPublish, draftKey) takes whichever draft row a package-agnostic findOne returns first.
  • This diff moves the draft read onto the save's address. In [null-draft, P-draft], the read, the save, the token and the discard now all name P's draft. A publish naming none can still promote the unbound draft (publishPackageDrafts can promote (and drain) ANOTHER package's draft row — the promote resolves the draft without the ADR-0048 package dimension #8907's storage-order coin toss).
  • In ①.3's corner the read now answers 404 NO_DRAFT, while the publish and the discard naming none act on Q's draft. ADR-0005 makes the read's 404 mean the same thing as the publish's empty slot.
  • On main the read sided with the publish. This is an existing split among the draft doors that this diff exposes, not a regression of the publish door.
  • Direction for the card: the publish and the discard naming none resolve their address as the draft save does, or refuse when it is ambiguous.

E3 (note only). In [null, P, Q] the read naming none serves the first-match body with the unbound row's version. This is leftover from #23165. Both writes are refused 409, so the token can never be spent.

Ablation.

  • The dev committed the fix first, then ran scripts/ablation-replace.mjs: the anchor went from 1 to 0, the blob changed, and the restored blob equals HEAD.
  • Reported red (4): the five steps, unbound-row-first order, the draft reads, the edge. Reported green (2): unbound-row-second order, the stand-in.
  • My reading predicts the same split. Without the line, the first match serves whichever row was stored first, and the edge's rows after DELETE ?package=Q are [null, P] in that order.
  • Credible. Not re-run, since this review is read-only. Its scope ends at getMetaItem, so it says nothing about ①.9.

Neighbours on protocol.ts.

Provenance. The PR body and the report say 890bf1d3 reverts the adopt attempt. The revert is 5e945626; 890bf1d3 adds the changeset. Fix 2 corrects this.

Tier face. The file list touches no published schema and no governed path, and the clause is no, so no face makes this review owed. It is served at CONTRACT_REVIEW_TIER on the claim and the maintainer's order, and the queue guard reads no record for this PR.

Implemented-by: session_01Deixz1D9pqfenZADBeqrAR
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: FAIL

  1. Move the layered read onto the same address (①.9).
    • In getMetaItemLayered, select the overlay row for a request naming no package at the address packagelessAddress resolves, as getMetaItem now does.
    • Change the content only. The lock stays at request.packageId, as the gate judges it.
    • effective then follows, and so do /layers, ?layers= and /published on both hosts.
    • Pin it in the [#23291] block, in both storage orders: getMetaItemLayered({ type, name }) returns overlay and effective equal to P's row and to getMetaItem({ type, name }).item.
    • Keep a control: a layered read naming a package with no row still reports the unbound row.
    • Re-run the ablation over the new pin.
  2. Make the changeset and the PR body say what the diff publishes (②).
    • (a) Name the layered and published reads among the doors whose served row changes.
    • (b) State ①.3's corner, and pin it. Naming no package, for an item whose active rows are bound to P and whose only draft is bound to another package, the strict draft read now answers 404 NO_DRAFT; that draft is read with ?package=. The draft preview serves P's active row.
    • (c) Replace "no request is refused that was accepted before", and the PR's "No door reaches different rows through it any more", with sentences that hold.
    • (d) Correct the revert attribution: it is 5e945626, not 890bf1d3.
    • The grade stays patch with Clause-②: no.

Reviewed 2026-10-14T13:28Z, read-only: the card and every comment on it, the PR body, its file list and three-dot diff, origin/main, and the head's check-runs.


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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Served on opus by the maintainer's order 「后续契约复审用 opus就够了」 (seat note 6166448512 on #6367).
Head-sha: e39d82deb3967107a67254da67653b14a3cedd8d
Local-runs: none

Re-review after the FAIL at 52bfacaa04 (6169548673). The patch round added three commits: b463e4be (the layered read), 5eb966c0 (the changeset) and e39d82de (the outage cause). The PR body was also rewritten.

Inputs.

Check-runs on the head (read at 14:45Z): 47 runs. 39 succeeded, 8 were skipped, none failed and none are in progress.

  • Skipped:
    • Auto Label and Check PR Size: their re-runs after the 14:36Z body edit were skipped, and their first runs succeeded;
    • Build Docs;
    • Console Pin Gate;
    • Dogfood Regression Gate (matrix/3);
    • Dogfood Verify CLI;
    • Packed-tarball smoke (opt-in);
    • Temporal Conformance.
  • Required contexts, all success: Lint & Repo Gates, TypeScript Type Check, Test Core and all six shards, Build Core, Governed Surface Queue Guard, and Check Changeset (both runs).
  • Green covers only what the suite pins. No pin reaches ①.5.

① Derived judgments

  1. Fix 1, the layered read: DISCHARGED.

    • getMetaItemLayered now takes its overlay from contentPackageId = request.packageId || packagelessReadAddress(type, name).
    • getMetaItem uses the same helper. Both reads resolve the address that packagelessAddress gives the save, the delete and the token.
    • Every door goes through this one method:
      • RestServer /layers and ?layers= (serveMetaItemLayered, which passes ?package= only when the request names one);
      • RestServer /published ({ type, name });
      • the dispatcher's answerMetaLayered, for both spellings;
      • the dispatcher's /published twin ({ type, name }).
    • effective follows. effectiveBase is the overlay, with extenders folded in. Both reads carry the lock family resolved at the request's address.
    • The lock stays at the request's address. overlayLockLayerAt and artifactLockLayerAt still take request.packageId.
    • In [null, P, Q] the helper answers undefined, so the read takes the first match, as before.
    • Pins:
      • In both storage orders, the overlay is P's row. effective.label equals the plain read's label. The overlay equals the overlay of the layered read naming P.
      • The five-step pin checks the layered read at step 3.
      • The layered stand-in control names OTHER_WRITABLE and gets the unbound row.
      • Matching by label is enough: the two candidate rows differ by label, and the decorations differ by design.
    • Ablation of the layered line: 2 red, 37 green. This matches my reading. Without the line, the any-row candidate serves whichever row was stored first, so only the unbound-first cases go red. The controls name a package and stay green.
  2. The resolution moved outside the catch: RIGHT.

    • packagelessAddress has its own catch, which calls rethrowUnlessMetadataStoreUnprovisioned and then metadataReadFailureError(driverError). That builds the 503 SERVICE_UNAVAILABLE with cause set to the driver's error, or the [Decision] metadataStoreUnavailableError destroys a producer's userMessage mark AT THE PRODUCER — a metadata app's marked refusal on sys_metadata can never reach any door #12536 marked refusal.
    • Inside the try, the outer catch wrapped that 503 a second time, so cause became the envelope. expectStoreUnavailable asserts caught.cause is the driver's error, so that test caught it. Outside the try, the envelope propagates as built.
    • Placement. The call comes after the code layer's MetadataManager.get() 丢弃 loadDiagnosed 的 degraded 判定:loader 读不到与「这一项没声明」在 6 个消费点上不可分辨 #5840 degraded check, so the two 503s keep their order.
    • Unprovisioned table. It still returns normally. The helper finds no binding, and findServedOverlayRow's benign catch leaves the overlay null.
    • The doors:
      • RestServer /layers and ?layers=: handleRouteError answers 503.
      • RestServer /published: rethrows any declared status, so 503.
      • The dispatcher's answerMetaLayered: errorFromThrown answers 503.
      • The dispatcher's /published twin swallows every overlay-read failure and falls back to the code/package snapshot. It did the same on main; RestServer's own comment calls this the deliberate divergence. This diff does not change it.
    • So every door that answered 503 on main still does, with the same cause.
    • getMetaItem's resolution, from round 1, also sits outside every try, with the same envelope.
  3. Fix 2, what publishes: (a), (b), (d) and the overstatement are DISCHARGED. (c) is NOT (see ①.5).

    • (a) The changeset and the body both name the item read, the layered and ?layers= reads (overlay and effective), /published on both hosts, and the draft reads.
    • (b) Both state the corner, and it is pinned:
      • 404 NO_DRAFT, checked by status and code;
      • ?package=Q serves Q's draft;
      • the preview serves P's active row, with no _draft.
      • The plain-line ablation reports the corner pin red.
    • (d) 5e945626 is right. From b776dcd1^ to 5e945626, sys-metadata-repository.ts nets to zero; 890bf1d3 only adds the changeset.
    • The overstatement is corrected: "For an item with no binding at all, the package-less row is still an authoring destination".
    • (c)
      • The PR's "No door reaches different rows" is gone. Its replacement holds: it leaves out the draft publish and discard, and names their card.
      • The changeset's "no request is refused that was accepted before" now reads "No write is refused that was accepted before".
      • The body adds "Nothing new is accepted" and "the one answer that moves from 200 to 404".
      • None of these three holds. See ①.5.
  4. Round 1's judgments, re-run over the whole diff: they STAND. That covers ①.1–①.8 of 6169548673: the plain read; its agreement with the token, the list, the save, the delete, the lock and the envelope; the draft reads; [null, P, Q]; the delete; the stand-in; and the unchanged surfaces. The only change to getMetaItem in this round moves it onto the helper, and that is equivalent: the helper returns packageId ?? undefined, and packagelessAddress never yields ''.

  5. NEW: the object save's destructive-change check reads the item naming no package. WRONG as published. Where the save writes P's row the behaviour is right, but it is unstated and unpinned, and one corner regresses.

    • How the check works. For an object save without force, saveMetaItem reads existing = await this.getMetaItem({ type, name }), naming no package. detectDestructiveObjectChanges then answers 409 DESTRUCTIVE_CHANGE on a removed field, an incompatible type change, or a field made required with no default.
    • What this diff moved. Since round 1, in [null, P] that read serves P's row. Before, it served the first row a package-agnostic findOne returns: the unbound row, which the double and the card's sequence store first.
    • Reachable. Objects take ?package= (sys-metadata-repository.package-writability.test.ts). ADR-0029 D9.9 accepts a package-less row beside the owner's.
    • Newly refused. Unbound {a} stored first, P {a, c}. A package-less save of {a}, which writes P's row: request.packageId is resolved to P above the check (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).
      • main judged it against the unbound {a}, accepted it, and dropped P's c with no confirmation.
      • The head answers 409 "Field 'c' removed".
      • A save naming P behaves the same.
    • Newly accepted. Unbound {a, d}, and P {a} written with force. A package-less save of {a}: main refused it 409 over the unbound row's d; the head accepts it.
    • For those saves the head is the right answer: the check now judges the row the save overwrites. On main it judged a row that no package-less save writes.
    • The corner that regresses. A save naming a third package Q that has no row, on an object no code artifact owns (D9.9 refuses nothing there).
      • The save writes Q's row. A read naming Q serves the unbound stand-in.
      • With the unbound row stored first, main judged the save against that stand-in. The head judges it against P's row, which the save never touches.
      • Example: unbound {a}, P {a, c}, a save of {a} naming Q answers 409, where main answered 200.
      • On main this depended on storage order: with P stored first, main answered as the head does. The check has never read at the save's own package. But in the order the card's own sequence produces, this refusal is new with this diff.
    • Unpinned. No pin reaches any of this, so the green check-runs say nothing about it.
    • The changeset's "No write is refused that was accepted before" and the body's "Nothing new is accepted" are false. Fixes 1 and 2.
  6. Other in-process readers of the item naming no package: RIGHT. They follow the read.

    • plugin-security's catalog echo registers getMetaItemLayered({ type, name }).overlay, owned by getMetaItem({ type, name }).item._packageId.
      • At 52bfacaa the two disagreed: the echo registered the unbound body as owned by P.
      • At this head both name P's row, which is the row the package-less save just wrote.
      • So on an environment kernel, a permission or position in [null, P] is now echoed with P's body.
    • The packaged-permission-set lock gate reads envelope.code only, so it is unchanged.
    • Readers that now see P's object in this state:
      • the data doors' object reads: the enable of an import's reference target, and the export and template fields;
      • seed apply's getObject;
      • the post-publish build probes;
      • the event doors' read gate (fetchCurrentMetaDocument).
    • None of these is a write door's own rule; they follow the read whose served row the changeset names. The changeset should say so in one line (fix 2).
    • Cost. A layered read naming no package now runs one more sys_metadata find, and that includes /published.
  7. Composition with main: RIGHT.

② Semver level

The changeset declares @objectstack/metadata-protocol: patch with Clause-②: no. That line is also line 2 of the PR body, and Check Changeset passed. Both stand.

  • The read change is a defect fix, as 6169548673 ② found. The draft corner's 404 is the absence answer at the draft save's address.
  • The destructive check (①.5) now refuses some requests it accepted, and accepts some it refused. Its rule and its answer have not changed: 409 DESTRUCTIVE_CHANGE, with force as the remedy.
    • What moved is the row it judges. It used to be a row no package-less save writes; now it is the row the save overwrites.
    • That is the same defect, fixed. No key, export, schema or error code moves, so no with no arm, and patch.
  • The third-package corner is the one place where the check now judges a row the save does not write. Settled under fix 1, it needs no arm either.
  • The changeset still has to say what moved (fix 2).

③ Boundary flags

The 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 pr_patch op?

  • This is a tooling question, not this card's.
  • PATCH on the issue endpoint for a PR number is the endpoint GitHub documents for editing a PR body, and the body was read back identical. It has no contract effect.
  • The seat answers it (A) or routes it to the relay's owner.

Dev open question 1: resolve packagelessAddress once.

Carriers for 6169548673.

New in this review.

Implemented-by: session_01Deixz1D9pqfenZADBeqrAR
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: FAIL

  1. Settle and pin the object save's destructive-change check (①.5).
    • Settle the third-package corner in one of two ways:
      • (preferred) have the check read at the save's own resolved address, packageId: request.packageId, which a package-less publish-mode save has already resolved to P above the check. Each save is then judged against the row its own address reads, and the corner is closed;
      • or keep the check as it is, state the corner (fix 2), and give it a carrier.
    • Pin it in the [#23291] block, on an object in [null, P] with the unbound row stored first:
      • a package-less save that drops a field only P's row has is refused 409 DESTRUCTIVE_CHANGE, the rows are unchanged, and the same save is accepted with force;
      • a save that drops a field only the unbound row has is accepted;
      • on the preferred route, a save naming Q is judged against the unbound stand-in.
    • Ablate the pin.
  2. Make the changeset and the PR body hold for writes.
    • Replace these sentences with ones that hold:
      • in the changeset, "No write is refused that was accepted before";
      • in the body, "No write is refused that was accepted before. Nothing new is accepted.";
      • in the body, "the one answer that moves from 200 to 404".
    • Name the destructive-change check: in this state its 409 DESTRUCTIVE_CHANGE now judges the package's row, in both directions. State the corner too, unless fix 1 closed it.
    • Say in one line that in-process readers of the item naming no package follow the read: the data doors' object reads, seed apply, and the security catalog echo.
    • The grade stays patch with Clause-②: no.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Served on opus by the maintainer's order 「后续契约复审用 opus就够了」 (seat note 6166448512 on #6367).
Head-sha: 8e3b9491c555681803f9ab82a9192eb8c169fe24
Local-runs: none

Re-review, round 3. This follows the FAIL at 52bfacaa04 (6169548673) and the FAIL at e39d82deb3 (6171238772). This round added two commits:

  • c9e2de86ae: the destructive-change check now reads at the save's own address, with three pins.
  • 8e3b9491c5: changeset text only, +9/−2.

The PR body was rewritten again.

Inputs.

The net diff. The code has not changed since c9e2de86.

  • .changeset/23291-packageless-read-serves-save-address.md: +25.
  • packages/metadata-protocol/src/protocol.ts: +58/−3.
    • getMetaItem's content address and its two uses.
    • getMetaItemLayered's content address, outside its try.
    • The packagelessReadAddress helper.
    • One spread in saveMetaItem's destructive-change check.
  • packages/metadata-protocol/src/protocol.packageless-save-address.test.ts: +271.
  • No published schema and no governed path.

Check-runs.

  • 8e3b9491 (the head): none. There were 0 check-runs and 0 statuses at 15:36Z, and again at 15:45Z.
    • At 15:45Z the PR API still names c9e2de86 as the head.
    • It reports mergeable: null and mergeable_state: unknown.
  • c9e2de86: 47 runs at both readings: 39 succeeded, 8 were skipped, none failed and none are in progress.
    • Skipped:
      • Auto Label and Check PR Size: these are re-runs after the 15:24Z body edit, and their first runs succeeded;
      • Build Docs;
      • Console Pin Gate;
      • Dogfood Regression Gate (matrix/3);
      • Dogfood Verify CLI;
      • Packed-tarball smoke (opt-in);
      • Temporal Conformance.
    • Required contexts, all success: Lint & Repo Gates, TypeScript Type Check, Test Core and its six shards, Build Core, Governed Surface Queue Guard, and Check Changeset (both runs).
  • What those runs cover. From c9e2de86 to 8e3b9491 only the changeset text moves. So the code and pins at the head are exactly what c9e2de86's test and type runs passed. Check Changeset, and the lint families that read .changeset/, have not run on the new text.
  • CI on the final head has not reported. This is landing condition 1, below.

① Derived judgments

  1. Fix 1 (6171238772 ①.5): the check reads at the save's own address. DISCHARGED.

    • The change. At the check, the read is now getMetaItem({ type, name, ...(request.packageId ? { packageId: request.packageId } : {}) }).
    • P is resolved before the check runs.
    • The pins. They sit in the [#23291] destructive block. The fixture stores the unbound {a, d} before P's {a, c}.
      • Package-less save of {a, d}: answers 409 DESTRUCTIVE_CHANGE, asserted by status and code, and every row and its fields are unchanged. With force, P's row becomes {a, d} and the unbound row is untouched.
      • Package-less save of {a, c}: accepted. It writes P's row and leaves the unbound row untouched.
      • Save naming Q, which has no row: {a, d} is accepted and writes Q's row. {a, c} drops the stand-in's d and answers 409 DESTRUCTIVE_CHANGE.
      • These are the three pins 6171238772 asked for.
      • Non-blocking: unlike the first pin, the third pin's refused leg does not assert that the rows are unchanged.
    • The ablations match my reading.
      • The destructive line removed alone: the check reads naming no package, and round 1's resolution answers that with P. Pins 1 and 2 stay green. The third-package pin goes red on its accepted leg, because judged against P's {a, c} the save drops c. Reported: 1 red, 41 green.
      • Both lines removed: the read takes the first stored row, the unbound {a, d}. Pins 1 and 2 go red. The third-package pin passes, as main does in this storage order. Reported: 2 red, pin 3 green.
      • The plain-read line removed alone would also leave pins 1 and 2 green, because the check then names P itself.
      • So pins 1 and 2 are held by both lines, and the third-package pin belongs to the destructive line alone. That is redundancy, not a gap.
      • The dev reports the file restored byte-identical to HEAD. Credible; not re-run.
    • Every other caller of the check. detectDestructiveObjectChanges has one call site.
      • A draft-mode save naming no package.
      • A draft-mode save naming Q, which has no row. c9e2de86 moves it from P's row to the stand-in. That equals main in the card's storage order, and the third-package sentence states it.
      • A save naming P. It is judged against P's row, where main judged the first stored row. The header sentence ("judges the row the save overwrites") states this, though no bullet names it.
      • The package doors.
        • duplicatePackage names its target. On its duplicate-again face it reads the target's own row, as before, wherever the target holds one row.
        • The unmanaged import names its target and writes only names its collision check cleared, so there is no existing row to judge. Unchanged.
        • The package publish's _unpublished flip saves an app, not an object. Unchanged.
        • Draft promotion does not run the check. Unchanged.
      • migrateStoredMetadata passes force: true, so the check is skipped. Unchanged.
      • os migrate organization-ownership --apply makes a package-less publish save, so it follows the package-less rule above.
      • A failed read still skips the check through its catch, as before.
    • No other write rule reads the item.
      • Inside metadata-protocol, getMetaItem has two internal callers: the cached read and this check. The check is the only write-door rule among them.
      • The _lock gate, authoredLockFamily, packagelessAddress and D9.9 each read rows at their own address. This diff does not touch them.
  2. Fix 2 (6171238772 2(c)): the changeset and the body now hold for writes. DISCHARGED.

    • Removed:
      • the changeset's "No write is refused that was accepted before";
      • the body's "No write is refused that was accepted before. Nothing new is accepted.";
      • "the one answer that moves from 200 to 404", which now reads "The draft read's corner: a 200 becomes a 404".
    • Now present in both the changeset and the body:
      • the destructive check, in both directions;
      • the third-package case;
      • "The rule and its remedy (force) are unchanged";
      • the in-process readers line: the data doors' object reads, seed apply's object lookup, and the security catalog echo.
    • The "package-less save or delete" correction is made. The body now says the unbound row is "no longer written by a package-less save or delete", and adds that "A package-less publish can still promote an unbound draft into it".
    • Each replacement sentence, checked:
      • "Before, it read naming no package and judged the first stored row, which is the package-unbound row in the order that saves produce it." TRUE.
      • "No key, export, schema or error code moves." TRUE. DESTRUCTIVE_CHANGE, NO_DRAFT and RESOURCE_CONFLICT all existed before.
      • "Apart from the destructive-change check above, no write door's verdict moves." TRUE of every metadata write door (see the end of ①.1).
        • Precision note, non-blocking: the changeset also uses "doors" for the data doors.
        • In [null, P], where the two rows differ, some consumer verdicts that follow the read can move:
          • the import door's reference-target exposure (servesReferenceTarget in core's import-runner);
          • the data doors' grants from a permission set the security catalog echoes.
        • The bullet just above that sentence names both readers, so nothing is hidden.
        • "No metadata write door's own rule moves" would be exact. Make that edit if the changeset is touched again.
      • The body's "(c) needs no new refusal" (under "Why (c)") reads as "no new refusal rule". The moved 409 is stated two sections above it. Non-blocking.
    • No new false "nothing is refused" or "nothing new is accepted" claim appears.
  3. Earlier discharges, re-checked over the whole net diff: they STAND.

    • They cover the plain read, the layered and published reads, the draft reads and their 404 corner, [null, P, Q], the delete, the stand-in, and the outage cause.
    • That is 6169548673 ①.1–①.8, and 6171238772 ①.1–①.4 and ①.6.
    • c9e2de86 touches none of those hunks, and 8e3b9491 touches no code.
  4. New input: seat 4's pointer 6171964742, the object registry's sentinel owner. It is not a defect of this diff, and it cannot be fixed inside the card's three options. It needs a carrier, and this PR closes the only one it names.

  5. Composition with main now: RIGHT, though GitHub has not yet computed mergeability.

② Semver level

The changeset declares @objectstack/metadata-protocol: patch with Clause-②: no, and the PR body carries the same line on line 2. Both stand.

  • The read change is the defect fix 6169548673 ② found. The draft corner's 404 is the absence answer at the draft save's address.
  • The destructive check:
    • Its rule, its error code and its force remedy are unchanged.
    • What moved is the row it judges: from one no package-less save writes, to the one the save overwrites.
    • The saves it now accepts were refused over a row they do not write. Removing that false positive is not the widening the triage (6149653063) routes to the spec lane.
    • The saves it now refuses would have dropped fields from their own row without confirmation.
  • 8e3b9491 changes text only.
  • No BREAKING banner and no ADR-0087 marker are owed.

③ Boundary flags

  • 6171238772's two fixes: both discharged (①.1, ①.2). Everything discharged earlier still stands (①.3).
  • Option (c) stays upheld.
  • 6171238772's escalation, the ①.7 cost note: answered by seat note 6171252768. It was declined and recorded, with a card owed only if a latency impact is measured. The dev's Q0 was answered A. Closed.
  • Dev report 6171857124: no open questions. Its one out-of-scope carrier 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, which is open at priority:p1 and pm:queue and carries E1 and the draft-save receipt.
  • New, ESCALATED: seat 4's sentinel-owner pointer (①.4). It is a carrier gap, not a fix for this diff.
  • Non-blocking:
    • the "no write door's verdict moves" precision note (①.2);
    • the third pin's refused leg does not assert unchanged rows (①.1);
    • the draft-mode check is not pinned (①.1).
  • Tier face: the file list has no published schema and no governed path, and the clause is no. The review is served at CONTRACT_REVIEW_TIER on the claim and the maintainer's order.

Implemented-by: session_01Deixz1D9pqfenZADBeqrAR (branch claude/issue-23291-package-address)
Reviewed-by: session_01Rerax7QTjKMPCUZxQUtPFR

VERDICT: PASS

Landing conditions (not fixes):

  1. CI on the final head has not reported yet.
    • At 15:45Z, 8e3b9491 has no check-runs, and the PR still names c9e2de86 as its head.
    • Before landing:
      • the PR must sync to 8e3b9491;
      • its required contexts must report green there, including Check Changeset on the new changeset text;
      • GitHub must compute it as mergeable.
    • If the PR never syncs, the head needs a fresh push event for CI to run.
  2. Seat 4's pointer 6171964742 needs a carrier that survives this merge (①.4). Either the seat files the sentinel-owner card, or 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 kept open: the PR's closing line is changed, or the card is reopened.

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; origin/main since the merge-base; and the check-runs on both shas.


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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 16:33
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 38599104664 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test Core (6/6) — 失败步骤: Run this shard's tests

    @objectstack/runtime:test:  FAIL   local  src/domains/meta-published-runtime-publish.test.ts > #8031 — GET /meta/:type/:name/published resolves from the published store > serves a RUNTIME-published it
      ↳ 失败原因: @objectstack/runtime:test: Error: Test timed out in 5000ms.
    @objectstack/runtime:test:  FAIL   local  src/domains/share-links-refusal-order.test.ts > [#23328] share-link resolve refusal order — the dispatcher twin > [#23372] email audience: the refusal is the 
      ↳ 失败原因: @objectstack/runtime:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • src/domains/meta-published-runtime-publish.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • src/domains/share-links-refusal-order.test.ts — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 7 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Queue removal triage: the same tree re-queued once

domain:engine seat 1, session_01Rerax7QTjKMPCUZxQUtPFR, 2026-10-14T17:16Z.

What failed. Queue run 38599104664 ran on main 222293f60a (#23510) + this PR at 46282fff92. Job Test Core (6/6) had 2 of 5,592 @objectstack/runtime tests fail, both with Test timed out in 5000ms:

  • meta-published-runtime-publish.test.ts:210, which serves a RUNTIME-published item through /published;
  • share-links-refusal-order.test.ts:356, which this diff does not reach.

Why it is not this PR's. The dev measured it in the foreground on a local merge of origin/main 222293f60a into 46282fff. The merge was clean, and the delta against main is the same 3 files.

  • :210: 776, 710 and 768 ms over three runs, 12/12 each time.
    • With this PR's protocol.ts swapped for main's and rebuilt, it ran 639, 880 and 751 ms: the same range. It is the file's first test, so it pays the warm-up.
    • The diff adds no measurable time, and the test does not hang.
  • The share-links file: 60/60.
  • @objectstack/metadata-protocol in full: 236 files passed, 28,408 tests.
  • The runtime /published and /layers parity files: 401/401.
  • The failing shard ran 1,625 s. The same shard took 980 s on the fix(metadata-protocol)!: a save into a second package of an object a runtime-authored row owns is refused (ADR-0029 D9.9) #23510 group and 1,058 s on this PR's own run. The runner was slow; both reds are timeouts and neither is an assertion.

Action: nothing was pushed, and the head stays 46282fff92 (ACCEPT 6173215707). The seat re-queues the same tree once. If the queue goes red again, it is treated as real and root-caused, not re-queued.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 47d569e Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23291-package-address branch October 14, 2026 17:42
marchtian pushed a commit that referenced this pull request Oct 14, 2026
…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
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/m tests tooling

Projects

None yet

2 participants