Skip to content

fix(metadata-protocol): an unshipped email-template locale keeps its write before the pair is ensured; the named-base refusal names os migrate packageless-overlays - #30322

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-k9ymj9
Nov 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-k9ymj9

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Bill · project thread

domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

维护者速读

  • 改了什么: 给系统自带的邮件模板加一个基础包没有的语种(比如 showcase 只带英文,管理员加中文),在新装环境里现在能直接保存;指名基础包的写入(?package=BASE)同理。基础包已有的语种照旧被拒,并指向 .custom 配对包。
  • 为什么改: 代码注释写明这种写入应保持开放,但新环境里配对包还没建出来,判断「能否写进配对包」时拿到「不归我管」,被当成「能」,于是先拒一次;照报错去配对包写,又因基础包没有这个语种被拒第二次,翻译加不进去。拒绝句另补上 os migrate packageless-overlays,告诉运维这条命令能把老的基础包绑定行挪进配对包(metadata-protocol: the #28905 overlay-write refusal sentence does not name os migrate packageless-overlays, the ceremony that now moves a base-bound row into its pair #29479)。
  • 风险与回滚: 不加参数、不改错误码和状态;只改配对包尚未安装时的判断依据(按 ensure 门将要给它的依赖闭包)。指名基础包的拒绝句措辞变了(为塞进 500 字上限而收短),dogfood 钉住的 rollback 片段原样保留。回滚即撤 PR。
  • 先看: packages/metadata-protocol/src/protocol-packaged-base-refusal.ts(overlayPairFixOf 新增的 pendingPairScope、overlayPairFixAdmits 的调用、closedOverlayWriteClauses 的 boundRowServed),packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts(新 describe「a fresh install, the pair not yet ensured」)。
  • 两分钟验证: pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.named-base-overlay-write.test.ts -t "fresh install":三条全绿,两条无包和指名基础包的 fr-FR 写入成功,en-US 对照仍是 403 NOT_OVERRIDABLE。
  • 需要你做的: 无,席位落地。

Fixes #30168
Fixes #29479

Clause-②: no — the fix restores the write packagelessCloseSubject's docblock documents as open ("An email_template member at a locale the base does not ship is refused there (#28777) and stays open"); a pair that is installed was already answered that way, and nothing outside that documented set is admitted.
Metadata-protocol: semantic — an author's package-less (or named-base) save of an unshipped email-template locale, refused 403 NOT_OVERRIDABLE on an install whose pair was not yet ensured, now succeeds.
Skills-docs: none — skills/objectstack-platform/SKILL.md line 128 (PR #29924) already states the package-less write of an unshipped locale is kept ("an email-template row at a locale the base does not ship moves as the pair's own member and keeps that write"); this PR makes the runtime match it.

What changes

#30168 — the close read "no such package" as "admitted". Both overlay-write closes (packagelessCloseSubject, and namedBaseCloseSubject for ?package=BASE) close a write only where the fix they name can be taken, and ask the override write gate about the declared body written into the pair (overlayPairFixAdmits). The gate reads the written package's closure from the registry (resolveWritePackageScope). On a fresh install the pair BASE.custom is not installed yet, so that read answers undefined and overrideWriteVerdict answers not_concerned, which overlayPairFixAdmits treats as "not refused". The close then closed a write whose fix is refused once the pair exists (declared_package_ships_no_such_item): the card's double refusal. Every existing pin booted with the pair installed, so none saw it.

  • overlayPairFixOf now carries pendingPairScope when the pair is not installed: the closure the ensure door gives it (one dependency, the base, manifest.dependencies: { [base]: range }), so the base plus what the base reaches, read with resolvePackageClosure, the gate's own walk.
  • overrideWriteVerdict (module function and the class method) takes an optional writtenScope used in place of the registry read for that package. Only overlayPairFixAdmits passes one; every door's own write passes none, so no door's verdict moves.
  • OverrideWriteVerdictAsk types the gate as both closes' hosts ask it (NamedBaseCloseHost, PackagelessCloseHost).

The first act's measurement (step 1 both ways). On a fresh showcase boot (pnpm dev -- --fresh, this branch), PUT /api/v1/meta/email_template/showcase_task_done_email:

  • with ?member=zh-CN: 400 VALIDATION_ERROR "The "member" query parameter is not supported by this endpoint … Supported parameters: force, mode, package, surface." The save door is body-addressed (META_ITEM_SAVE_PARAMS), so this spelling never reaches the close.
  • without it, with locale: 'zh-CN' in the body: the close reads the body's member (overlayMemberOfBody → zh-CN), the right one. The defect was the gate half above, not the member reading; the docblock is right and the code was wrong.

#29479 — the named-base sentence names the ceremony. Since PR #29477, os migrate packageless-overlays moves a row stored bound to the read-only base into the pair with its declaration; the refusal did not say so. closedOverlayWriteClauses gains two clauses, one spelling each: ceremony (the command, now used by both sentences, byte-identical in the package-less one) and boundRowServed, used by the named-base save, publish and rollback sentences: "… is served until moved by os migrate packageless-overlays, overridden by the pair, or deleted naming the base." The previous tail ("served until the pair declares the override, or a delete naming the base removes it") was shortened to fit: with the ceremony added in the long form, the file's 30-character-base / 40-character-name pin measured 525 (save) and 511 (rollback) characters against the REST door's 500 (CLIENT_MESSAGE_MAX). The phrase the dogfood suite pins ("a rollback of its row bound to the read-only base is closed") is unchanged. Docblocks that said the ceremony never moves a base-bound row (NamedBaseWriteOperation, namedBaseOverlayWriteRefusal in both files) are corrected.

Tests

packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts: makeProtocol({ pairInstalled: false }) boots the base with no pair, and a new top-level describe pins:

  • the package-less save of crm_welcome at fr-FR (the base ships the canonical member only) is accepted and stored package-less;
  • the save naming the base at fr-FR is accepted, as it is with the pair installed;
  • CONTROL: the canonical member is still closed onto the pair at both addresses, with the pair not yet ensured, and nothing is written.

expectClosedSentence now also asserts the boundRowServed clause on every named-base sentence; the 500-character pin covers the new length.

Red first, on origin/main 04f36b6
× the package-less save is accepted and served as the environment's own member
× the save naming the base is accepted, as it is with the pair installed
Error: 'email_template/crm_welcome' ships in 'com.acme.crm': save it into 'com.acme.crm.custom' (?package=com.acme.crm.custom), declaring overrides: { package: 'com.acme.crm', basedOn }. A write naming no package is closed where `os migrate packageless-overlays` can move its row (ADR-0146 D11); …
Tests  2 failed | 32 passed (34)

The CONTROL case was green on main too.

Ablation (one, the behaviour the new pins hold)

node scripts/ablation-replace.mjs --file packages/metadata-protocol/src/protocol-packaged-base-refusal.ts --anchor "item: declaredBody }, fix.pendingPairScope);" --replacement "item: declaredBody } /* ABLATED pendingPairScope */);" -- … vitest run src/protocol.named-base-overlay-write.test.ts -t 'fresh install' on b2fc8526. The suite imports the source (./protocol.js), so no dist/ leg applies.

ablation-replace: ok mutation landed: anchor 1 -> 0, blob 6d461885e9e6 -> 56db37f9ff9d
× the package-less save is accepted and served as the environment's own member
× the save naming the base is accepted, as it is with the pair installed
Tests  2 failed | 1 passed | 34 skipped (37)
ablation-replace: ok restored: blob == HEAD (6d461885e9e6) and `git diff HEAD` is empty

Signature notes (for the next PR on these functions)

classifyPackagelessRow is unchanged. overlayPairFixAdmits keeps its parameters and result; its host's overrideWriteVerdict is typed OverrideWriteVerdictAsk (one optional parameter, writtenScope), and it passes fix.pendingPairScope. OverlayPairFix gains the optional pendingPairScope, present only while the pair is not installed.

Acceptance notes

  • Live readings (fresh showcase boot, this branch): package-less zh-CN save 200, served at ?member=zh-CN as the environment's own member while en-US stays the package's; ?package=com.example.showcase fr-FR save 200; both en-US controls 403 NOT_OVERRIDABLE naming the pair, the named-base one with the new clause.
  • content/docs/concepts/metadata-lifecycle.mdx's D11 callout says a package-less write onto an item one read-only package ships is refused, without the unshipped-locale exception. This PR does not make it false (the exception was documented in code and skills, not there); left as is.

Verification

All on head b2fc8526 (after git merge origin/main, 1a06be80, then a rebuild: turbo run build --filter='!@objectstack/docs', 119/119).

  • pnpm --filter @objectstack/metadata-protocol test: Test Files 312 passed, 4 skipped; Tests 22382 passed, 26 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.
  • packages/objectql: environment-overlay-email-locale-restart.test.ts, protocol-packageless-overlay-migration.test.ts, environment-overlay-five-types.test.ts: 3 files, 106 tests passed (they read the rebuilt dist/).
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 60 derived families, each run, all exit 0; --ran reconciliation: "60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED".
  • pnpm lint: exit 0.
  • Not run locally, left to CI: the dogfood battery (no !, no (narrowing) arm), test:repo, the UI lanes.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01Utp6CYeyrEJGAte4R8B7un

…stall, before the pair is ensured

Claude-Session: https://claude.ai/code/session_01Utp6CYeyrEJGAte4R8B7un
Co-authored-by: Claude <noreply@anthropic.com>
…against the pair the ensure door would create, and name the ceremony in the named-base sentence

Claude-Session: https://claude.ai/code/session_01Utp6CYeyrEJGAte4R8B7un
Co-authored-by: Claude <noreply@anthropic.com>
…ose and the named-base sentence

Claude-Session: https://claude.ai/code/session_01Utp6CYeyrEJGAte4R8B7un
Co-authored-by: Claude <noreply@anthropic.com>
…gate it asks with the pending pair's closure

Claude-Session: https://claude.ai/code/session_01Utp6CYeyrEJGAte4R8B7un
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 5, 2026
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

10 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), packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/deployment/app-dependencies.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/deployment/validating-metadata.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/frontend/plugins/plugin-chatbot.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/kernel/contracts/metadata-service.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/permissions/authorization.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/permissions/capabilities.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/permissions/permission-sets.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/protocol/kernel/plugin-spec.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/ui/doc-pages.mdx (via packageId (symbol, a field of interface WrittenPackageScope))

⛔ 4 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/v18/18-0.mdx (via packageId (symbol, a field of interface WrittenPackageScope))
  • content/docs/releases/v18/18-3.mdx (via packageId (symbol, a field of interface WrittenPackageScope))

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
  • 1 cross-cutting symbol(s) contributed no route anchor: packageId (4 routes)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 c1240a38ac2c23c8d526e476a20edeabbbefd6f6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c1240a38ac2c23c8d526e476a20edeabbbefd6f6

⚠️ 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 c1240a38ac2c23c8d526e476a20edeabbbefd6f6 → 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#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb

席位核对 · ACCEPT · 2026-11-05T06:17Z · head b2fc8526

Reviewed against order server-seat5/dispatch-30168.md (Claims 6649805293 on #30168, the formal Claim on #29479 posted this round).

Landing: ready + auto-merge.

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