Skip to content

fix(metadata-protocol): the layered read on a base's address answers an email template locale its .custom pair adds with the pair's layer (#28830) - #28845

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-p5198q
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-p5198q

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #28830

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

What changes

Since PR #28828 (option A of #28779, ruling A on #28390), a base's address serves an email template member its .custom pair adds at a locale the base does not ship: the by-name reads naming the base or no package, the env-wide and base-scoped lists, getMetaItemsForExecution and sendTemplate. The layered read did not: GET /meta/email_template/<name>/layers?package=<base>&member=fr-FR answered 404 (no code layer, since the base ships no fr-FR, and no overlay, since the served-row candidates of the base's address hold no row at that member). ADR-0146 D8 lists the reads that must agree; this brings the layered read in line.

layered read on the base's address (?package=BASE or no package) before after
base's own member (en-US) code: base, overlay: null unchanged
pair's declared override (en-US) code: base, overlay: override unchanged
pair's own added member (fr-FR) 404 code: null, overlay/effective: the pair's row
a dependent's added member (fr-FR) 404 404 (unchanged; answers under ?package=DEPENDENT)
pair and dependent both at fr-FR 404 the pair's
any non-bundle type as before unchanged (the branch needs a bundle member)

packages/metadata-protocol/src/protocol.ts.

Tests

  • packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts (real showcase over HTTP, showcase_task_done_email): a layers(member, pkg?) probe on the rig and 2 new cases, plus layered assertions folded into 2 existing ones.
    • Pair: the layered read naming the base, naming no package and naming the pair answers {code: null, overlay: pair's subject, effective: pair's subject} for fr-FR; the base's en-US layers unchanged.
    • Dependent: fr-FR stays 404 on the base and package-less layered reads and answers under ?package=DEPENDENT; base en-US unchanged.
    • Pair beside dependent at fr-FR: the base's layered read answers the pair's.
    • Control: the pair's declared en-US override answers {code: shipped, overlay: override, effective: override}, and fr-FR on the base with only that override stored is 404.
    • RED on main 1920e480: 2 failed / 10 passed (the pair case and the pair-beside-dependent case). GREEN at c53513be: 12/12 (os-verify-lock VERDICT command-exit 0).
  • packages/objectql/src/protocol-bundle-member-base-address.test.ts (real protocol, real engine, in-memory driver): a layersOf helper and a [#28830] describe with 4 cases: the pair's added member on the base, package-less and pair addresses; a dependent's member 404 on the base and answered under the dependent; pair and dependent at one locale; control (declared en-US override, and a non-bundle view answering {code: base columns, overlay: null} as before). GREEN 10/10 at c53513be.
  • Ablation, one leg (the seam): if (row) served = { row }; → void row; through scripts/ablation-replace.mjs (anchor 1 → 0, restore blob == HEAD verified), metadata-protocol rebuilt and the mutation proved absent from dist/ by ablation-dist-preflight.mjs --absent. Unit: 2 red (the pair's added member; pair beside dependent), 8 green. Dogfood: 2 red (the same two), 10 green. The dependent cases and every control stay green. A restore build followed, the marker proved present again.
  • Package suites at this head: see Verification.

Acceptance notes

Verification

Package suites at c53513be: @objectstack/metadata-protocol typecheck + test (297 files passed, 4 skipped; 22080 tests passed), @objectstack/objectql typecheck + test (521 files, 10008 tests passed), both under the lock, VERDICT command-exit 0.

Gate sweep at c53513be: dispatch-gates --commands derived 66 families (the same 66 as PR #28828); 66 run, all exit 0 (dispatch-gates --ran reads "66 run, 0 NOT-MEASURED", a derived zero). One first ran exit 3 on a prerequisite and was re-run green: check:dual-build-cjs-loads (seven packages outside the dogfood closure were unbuilt). The record is pm-orders/server/28830-gates-ran.txt in the project files.

Full dogfood battery: not run. The arm is (widening) and the title carries no !; the two touched test files ran as above.

Changeset .changeset/28830-layered-read-pair-added-member.md: @objectstack/metadata-protocol patch, both declaration lines. CI families are read from this PR's check-runs, not re-run here.


Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw

…an email template locale its `.custom` pair adds with the pair's layer

`GET /meta/email_template/<name>/layers` naming the base or no package
answered 404 for a locale the base does not ship and its `.custom` pair
adds, while the by-name read, the base's lists and `sendTemplate` serve
it since #28802. The layered read now takes the pair's row as its
overlay through `findPairAddedMemberRow`, the one read the by-name read
shares (PR #28828's `pairOfAddedMember` predicate). A dependent's added
locale stays 404 there; non-bundle types are unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5m2Ryfx2Wubuz716mw
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

2 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))
  • 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))

⛔ 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 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 10 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 2cba2eb3f1f50ff1678dfa7b2925e37965b6f9c5 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 2cba2eb3f1f50ff1678dfa7b2925e37965b6f9c5

⚠️ 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 2cba2eb3f1f50ff1678dfa7b2925e37965b6f9c5 → 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

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

Contract review

Served-tier: TIER_DEFAULT
Head-sha: c53513be600f1dec1a07824f133c73691f8c7214
Local-runs: none

Reviewed at 2026-11-02T00:21Z against the merge-base 1920e4801ab96426bb624470bceedef41066ad16 (PR #28828's squash) for PR #28845 (card #28830, Fixes #28830), one commit, four files: .changeset/28830-layered-read-pair-added-member.md, packages/metadata-protocol/src/protocol.ts (+34/-8), packages/objectql/src/protocol-bundle-member-base-address.test.ts (+69), packages/qa/dogfood/test/email-template-dependent-unshipped-locale-read.dogfood.test.ts (+35/-1). Face: record-recognisers --paths decides no face on the four paths (no published schema, no governed rule text) and defers to the committed changeset's Clause-②: yes (widening) line, which is the declaration face: TIER_DEFAULT. Read-only: the diff, the card, the claim, the os-dev-report, the gate record, the PR body, the head's check-runs, PR #28828's two review records, ADR-0146 D8 (on origin/main), and the cited code at the head (readMetaItem, getMetaItemLayered, findServedOverlayRow / servedOverlayRowCandidates, pairOfAddedMember, registryMemberServedAt, bundleMemberDiscriminator, packagelessReadAddress / packagelessContentPackage / packagelessAddress, readVersionToken / storedHeadAt, servedLockState, the REST /layers route and createMetaLayeredAnswer). Nothing built, run or re-run.

① Derived judgments

1. Semantics of the layered read, by case (bundle types only; email_template is the one ITEM_KEY_DISCRIMINATORS entry today). Judged on the head's code. In getMetaItemLayered, contentPackageId is request.packageId or packagelessReadAddress, which at this head is packagelessContentPackage(packagelessAddress(...), shippedArtifactsOf(...)): the one code package that ships the name (BASE) whether the stored rows at the member are bound to PAIR, to DEPENDENT or to both (#28238, D8 item 1). So the package-less read and the base-named read take the same path below, and the by-name read's contentPackageId is derived the same way (readMetaItem, #28238), so the two reads agree by construction.

  • Pair's own added member on the base's address (?package=BASE or no package). The served-row resolution (findServedOverlayRow, servedOverride, candidates: the base's declared override, the base's own row, the package-less row) finds nothing at fr-FR; the new branch runs (!served, wantedMember = fr-FR, contentPackageId = BASE): findPairAddedMemberRow asks pairOfAddedMember (BASE ships the name, does not ship fr-FR, BASE.custom is its pair) and reads findServedOverlayRow({ packageId: PAIR, member }): the pair's row. overlay is that row, overlayScope: 'env', code stays as resolved (null: the service copy is another member, the artifact lookup at fr-FR misses, the registry's bare slot holds a tenant-authored hydrated row and runtimeOnly drops it), effective is the overlay (not orphaned, not a declining type). Before the diff all three were null, which createMetaLayeredAnswer refuses as absent and the door writes as 404. 404 → the pair's layer. Correct, as ordered.
  • A dependent's added member on the base's address. Same path to the new branch; pairOfAddedMember names only BASE.custom, findServedOverlayRow is asked for the pair's row and there is none; served stays undefined; overlay null, code null; the view-container expansion does not apply; the orphan block needs code !== null and is skipped. All three null → 404. Unchanged. Under ?package=DEPENDENT, contentPackageId = DEPENDENT and the first resolution serves its own row; the new branch is not reached (and pairOfAddedMember(..., DEPENDENT, ...) would answer undefined: it ships no bundle of the name). Unchanged, served under its own package.
  • Pair and dependent both at fr-FR. The first resolution on the base's address still finds nothing (a dependent's row is never a candidate of BASE's address: servedOverlayRowCandidates with a package is that package's row then the package-less row). The new branch names PAIR explicitly, so the dependent's row is never read. The package-less read resolves contentPackageId to BASE here too (packagelessAddress sees two bindings → null; packagelessContentPackage with one shipper → BASE). The pair's. Correct, and deterministic regardless of hydration order (the registry's bare slot is not consulted on this path).
  • The base's own member (en-US). When no row is stored, served is undefined and the new branch asks pairOfAddedMember(..., 'en-US'): BASE ships that member → undefined → no read. code the base's artifact, overlay: null. Unchanged.
  • A declared override (en-US in the pair with overrides.package = BASE). The first resolution's servedOverride candidate serves it; served is set, the new branch is skipped. code the base's, overlay the override, effective the override. Unchanged. The dogfood control also pins that fr-FR on the base stays 404 when only the override is stored.
  • ?package=PAIR. contentPackageId = PAIR; the first resolution serves the pair's own row at fr-FR. Unchanged.
  • Non-bundle types. bundleMemberDiscriminator returns undefined for a type with no ITEM_KEY_DISCRIMINATORS entry, so wantedMember !== undefined is false and the branch never runs; the const served → let served typing is a no-op. Byte-identical behaviour by construction; the objectql control pins a view on the base (code the base's columns, overlay: null).
  • /published and the other readers of getMetaItemLayered (meta-item-history-routes.ts, runtime meta.ts): the /published door reads the layered overlay for a name on the package-less address; for a pair-added member it now serves the pair's row where it answered the MetadataService's getPublished fallback or absence before. That is the same widening on the same seam, consistent with D8 ("the layered read's effective and the plain read serve one row"). Not pinned by the tests; noted, not a defect.

2. One seam, PR #28828's predicate. Exactly one new behavioural branch in getMetaItemLayered. The new private findPairAddedMemberRow is the #28802 branch of readMetaItem lifted verbatim: pairOfAddedMember (→ shipsBundleName, shippedArtifactsOf(..., member), customizationPackageId) then findServedOverlayRow({ packageId: pair, state: 'active', ...memberOption(member) }, activeRows), ?.row. The by-name refactor is behaviour-preserving: before, record was reassigned only when pairOfAddition !== undefined and was otherwise already falsy (served?.row of an undefined served); after, it is assigned the helper's result, which is undefined in exactly those cases and the same ?.row otherwise; storedRowServed (record !== undefined && record !== null) and every later read of record see identical values; activeRows is still forwarded. The bundle-type gate is the same table on both reads (bundleMemberDiscriminator → ITEM_KEY_DISCRIMINATORS; no 'email_template' literal in the diff). One seam, one predicate, no second spelling. Correct. The layered call passes no activeRows, so the helper reads the store once more there; the by-name read keeps its pre-read rows. Fine.

3. Lock, flags, stamp. overlayLockLayerAt and artifactLockLayerAt are asked at request.packageId as before, so the lock is unchanged. servedLockState's last argument (overlay !== null && !orphaned) is now true for a pair-added member on the base's address; per its docblock that boolean feeds only the origin-gated removal verdict, and the by-name read has reported the same (storedRowServed true through its 1b branch) since PR #28828, so the two reads agree. overlayRow is set from the pair's row; declinesStoredRow reads it for flow and code-defined datasource names only, neither a bundle type. The layered read reports layers, not a _packageId stamp of its own; overlay is the row as stored. Consistent.

4. Evidence.

② Semver level

  • Clause-②: yes (widening) — correct. A public read, GET /meta/email_template/NAME/layers?member=LOCALE naming the base or no package, answered 404 for a pair-added member and now answers 200 with the pair's layer (and /published follows). No address lost an answer: every other case above is unchanged, so there is no narrowing arm to declare. Title carries no !.
  • Metadata-protocol: none — correct. AGENTS.md rule 3 defines the line as the change to what a metadata author can write. Nothing authorable is added, refused or tightened, and no meaning or default changes: what a pair-added locale means on the base's address was decided by PR fix(metadata-protocol)!: an email template locale added in a .custom pair is listed and sent on the base's address; a dependent's added locale stays under its own package (#28802) #28828 (semantic there); this read reports it. none ⇒ patch on every introduced entry.
  • Changeset @objectstack/metadata-protocol: patch — correct. check-changeset-no-major.mjs's level axis: none grades every entry patch, with no authorable-surface baseline moved; the gate record has it exit 0 and Check Changeset concluded success on the head. Both declaration lines are line-initial in the PR body and the changeset body. No Skills-docs: line is owed (none kind, no (narrowing) arm). Full dogfood battery not owed ((widening) arm, no !).

③ Boundary flags

Author objectstack-fleet[bot]; no relay login to name.

Implemented-by: claude/project-thread-p5198q
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-11-02T00:23Z · head c53513be · #28830

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main with Fixes #28830 and a patch changeset, declaring Clause-②: yes (widening) and Metadata-protocol: none.
    • Not governed, +140 / -8. Landing tier S.
    • The claim (6565410834) names this PR's branch, claude/project-thread-p5198q.
  2. Against the order:
  3. Seat's decision on the version token: leave it, as a note. The base-named read of a pair-added locale carries no version token, because the token names the address a save would lock, and no door saves a pair-added locale through the base's address. A card is owed only if a client is measured relying on it.
  4. Contract review: PASS on this head (6566108977), with no must-fixes. Its docs should-fix is carried to docs(skills): a pair-added email template locale is served on the base's address; another package's added locale stays under its own package (#28802 follow-up) #28829 as a scope note.
  5. Gates: the dev reports 66 of 66 derived gates run, with none left unmeasured. CI on this head has 0 red at review, and auto-merge waits for the rest.
  6. Landing: pr_ready + automerge_enable through the relay.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 00:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 00:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 3e62a22 Nov 2, 2026
50 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-p5198q branch November 2, 2026 00:46
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

1 participant