Skip to content

fix(metadata-protocol)!: a save, publish or rollback naming a read-only base itself onto its view, dashboard, report, translation or email template is refused, naming the .custom pair - #29270

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-2isfgl
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-2isfgl

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Requested by Steve · project thread
Fixes #28905

Clause-②: yes (narrowing)
Metadata-protocol: narrowed
Skills-docs: none needed — skills/objectstack-platform/SKILL.md's ADR-0146 paragraph already places every customization of an installed base in its paired .custom, declaring overrides, and names ?package=BASE only as a read that serves it; nothing it says becomes false.

维护者速读

  • 改了什么:保存、发布(单项与 publish-drafts 批量)、回滚时直接写 ?package=只读基础包 到它自带的视图、仪表盘、报表、翻译或邮件模板,现在一律 403 NOT_OVERRIDABLE,提示改写进它的 .custom 配对包并声明 overrides——与 metadata-protocol: close the package-less write onto a packaged item (PUT/DELETE, rollback, source save, reset) naming <base>.custom; Studio drops its overlay link #28576 对“不写包名”的拒绝同一句修复。删除保持开放。
  • 为什么改:ADR-0146 D1 规定只读包的定制只落在配对包里;但写基础包本身仍会落下一行未声明的定制,盖住基础包文档、不随配对包导出,迁移仪式 os migrate packageless-overlays 也看不见它。
  • 风险与回滚:仅收窄写入口,不改存量行:以前这样存下的行照常生效,可删除,或被配对包声明的覆盖压过。回滚即撤销本 PR。另:os migrate organization-ownership --apply 把公开表单撤回带到代码包视图时,现会停下报阻塞并给出配对包写法。
  • 先看:packages/metadata-protocol/src/protocol-packaged-base-refusal.ts(namedBaseCloseSubject 的放行条件与句子)、protocol.ts 中 promoteDraftForPublish 的新判定(批量发布整批中止)、protocol.named-base-overlay-write.test.ts。
  • 两分钟验证:cd packages/metadata-protocol && npx vitest run src/protocol.named-base-overlay-write.test.ts,应见 17 个用例全部通过。
  • 需要你做的:无,席位落地。

What changes

@objectstack/metadata-protocol — an authoring write that names the read-only code package itself (?package=BASE) onto an item of one of the five overlay types that package ships is now refused 403 NOT_OVERRIDABLE, writes nothing, and names the fix the package-less close (#28576) names, in the same words: write it into BASE.custom (?package=BASE.custom), declaring overrides: { package: 'BASE', basedOn }.

  • Closed: the save (draft and active), the per-item publish and publishPackageDrafts (both through promoteDraftForPublish, judged on the package the active row is written under; the batch aborts all-or-nothing and failed[] names the item), and the rollback of a row bound to the base.
  • Closed only where the fix can be taken — namedBaseCloseSubject, which reads "packaged" exactly as the package-less close does (overlayBaseShippers, read-only shippers of the five types), with the named package among the shippers, and then checks the same steps: a pair id derives, the ensure door would not refuse the pair, the base records a version, and the override write gate admits the declared body into the pair. Anything else keeps the write it has on main.
  • Kept open: a delete naming the base and its draft discard (removing a row writes nothing, and it is the only way to clear a row stored before this change); a writable package; a name the base does not ship (ADR-0070 D1's 422 WRITABLE_PACKAGE_REQUIRED); a body carrying overrides (the D2 gate's declared_outside_pair sentence, unchanged); a base whose pair cannot be ensured or that has no version; an email_template locale the base does not ship; and the server-stated rewrites (source set, package-duplicate).
  • One spelling: closedOverlayWriteClauses now builds the item / base / pair / declaration / fix clauses for both closes. packagelessOverlayWriteRefusal's output is byte-identical (its pins in protocol.packageless-save-address.test.ts, protocol.packaged-base-refusal.test.ts and objectql's five-types file are unchanged and green). Only the rule clause after the fix differs.

The sentence, save of view/task.crm_list naming com.acme.crm (358 characters, against 382 for the package-less save of the same item; a 30-character base id with a 40-character name stays under 500 on every verb, pinned):

'view/task.crm_list' ships in 'com.acme.crm': save it into 'com.acme.crm.custom' (?package=com.acme.crm.custom), declaring overrides: { package: 'com.acme.crm', basedOn }. The read-only base itself takes no customization (ADR-0146 D1, D11): a row bound to it from before is served until the pair declares the override, or a delete naming the base removes it.

Measured before the change (origin/main 14208f2, stub engine, five types)

Door naming ?package=BASE Before After
save, active accepted: an undeclared row bound to BASE, served by the read naming no package and by ?package=BASE in place of BASE's document refused 403, nothing written
save, draft accepted refused 403
per-item publish accepted refused 403, draft stays a draft
publishPackageDrafts(BASE) promoted the draft (outcome: published) outcome: refused, failed[0].code NOT_OVERRIDABLE
rollback of a row bound to BASE accepted refused 403, row unchanged
delete naming BASE removes the row; the pair's row (if any) is served unchanged (open)
ceremony census (listPackagelessOverlays) lists none of these rows unchanged (out of scope)

A pair row declaring the override beside a row bound to BASE is served over it (read naming no package, ?package=BASE, layered effective), so the refusal's fix works for rows stored before.

Tests

  • New packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts (17 cases): the save naming the base, active and draft, for each of the five types; the per-item publish; publishPackageDrafts; the rollback; the 500-character bound at a long base id and name; and the controls: delete and draft discard naming the base stay open; the pair's declared save is accepted and served over a row bound to the base; a migrate-stored rewrite still writes; a writable package's same name; a name the base does not ship keeps WRITABLE_PACKAGE_REQUIRED; a base whose pair cannot be ensured keeps the write; a body carrying overrides keeps the D2 sentence.
  • packages/objectql/src/environment-overlay-five-types.test.ts: one real-engine case per type and topology (10): the save naming the read-only package, active and draft, is refused naming its pair and stores nothing.
  • Forced test edits (each wrote through the door this change closes):
    1. packages/metadata-protocol/src/protocol.packageless-save-address.test.ts › "an item a CODE package ships…" › "with an overlay row bound to the code package: the package-less save is refused, and that row is untouched". It seeded that row with saveMetaItem({ packageId: CODE_PACKAGE }). It now asserts that save is refused (403, names the pair, nothing written), then seeds the row directly (seedRow) as a build before this change left it. The rest of the case is unchanged.
    2. packages/objectql/src/environment-overlay-email-locale-restart.test.ts › "the package-batch doors and the cluster fan-out carry the member ([finding] email_template: a Studio save of the same template in a second locale replaces the first locale's overlay at the next boot (sys_metadata keeps one overlay row per type and name) #22877, stage 2)": four cases drafted email_template members into the read-only com.acme.mail itself and then asked publishPackageDrafts / revertCommit / discardPackageDrafts / listDrafts / listCommits of it. They now draft into its pair, declaring the override, and ask the same doors of the pair. Every member assertion is unchanged (35/35).
    3. packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts (landed with fix(runtime)!: package commit revert and rollback act only on a commit of the package their path names #29090 today) › its last control drafted a showcase view into com.example.showcase itself to give the read-only package a commit. It now asserts that draft is refused naming the pair. It then writes the same customization into com.example.showcase.custom, declared, and pins the commit's revert through the pair's own path, and through no other (404 through B, 200 and removed through the pair). Its header says why: a commit recorded before keeps its revert, since the commit doors still take no read-only guard, but a fresh boot can no longer make one.
Ablations (each red, restored, `git diff HEAD` empty)
  1. Save door throw removed (if (namedBase) throw namedBase; after the save's close): 6 red — the five per-type save cases and the forced pin (Error: expected the call to be refused, but it resolved; the report case got 422 from a later gate instead).
  2. Promotion throw removed: 2 red — per-item publish (expected undefined to be an instance of Error) and publishPackageDrafts (expected { success: true, …(7) } to match object { success: false, …(3) }).
  3. Rollback throw removed: 1 red — the rollback case.
  4. The overrides exclusion removed: 1 red — the D2 control (expected "'view/task.crm_list' ships in 'com…" to contain "only 'com.acme.crm.custom' may decl…").
  5. objectql real-engine pin, against the metadata-protocol dist: save throw neutralised with a marker, dist rebuilt, ablation-dist-preflight saw the marker in 2 built files: 10 red / 26 green of 36 (publish: expected undefined to match object { Object (code, status) }). Restored, dist rebuilt, preflight --absent saw the marker in none of 24 built files, tree clean.
  6. Dogfood pin (forced edit 3), against the metadata-protocol dist the boot consumes: same neutralised throw, marker present in 2 built files: 1 red / 6 green ({"success":true,…,"message":"Saved customization overlay (env-wide, package=com.example.showcase, state=draft) …"}: expected 200 to be 403). Restored, rebuilt, marker absent, tree clean.

Acceptance notes

Verification

All on head 29a58bb6 (origin/main 45e9b2f1 merged in), under os-verify-lock:

  • dispatch-gates --commands: 74 derived, 74 run, every exit 0; --ran reconciles (0 NOT-MEASURED, a derived zero). The first round reddened check:engine-double-contract on the new suite's fake engine; its pinned ledger now records the three doubles (scripts/engine-double-contract.pinned.json).
  • eslint over packages/metadata-protocol and the touched test files: exit 0.
  • pnpm --filter @objectstack/metadata-protocol typecheck 0 · test 301 files / 22117 tests passed.
  • pnpm --filter @objectstack/objectql typecheck 0 · test 527 / 10150 passed.
  • pnpm --filter @objectstack/runtime test 515 / 8329 passed · pnpm --filter @objectstack/rest test 367 / 6963 passed.
  • Dogfood battery ((narrowing) arm), full: 389 files passed, 3 skipped; 3215 tests passed, 28 skipped. Lock VERDICT command-exit 0.
  • check-changeset-no-major --event on this body: level axis green (yes (narrowing) + narrowed, minor). check-adr-0087-registration: the disposition is accepted.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (eslint over packages/metadata-protocol and the touched objectql test: exit 0).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a, not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the dispatch contract's Chinese summary).
  • The Before Submitting list in CONTRIBUTING.md is done.

…sed onto its pair

An authoring save (draft or active), a publish (per-item and the package
batch) or a rollback that names the read-only code package itself
(`?package=<base>`) onto an item of one of the five overlay types it ships
landed a row bound to the base with no declaration: served at the base's
address, outside the pair ADR-0146 D1 puts every customization in, and
not seen by `os migrate packageless-overlays`. Each is now refused
`403 NOT_OVERRIDABLE` with the package-less close's fix (the pair, its
`?package=` address, the declaration), one spelling of it, under the
500-character bound. Closed only where that fix can be taken; a delete
naming the base stays open (the one way to clear a row stored before).

Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8
Co-authored-by: Claude <noreply@anthropic.com>
… the pinned ledger

Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@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 16 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 NamedBaseWriteSubject))
  • content/docs/deployment/app-dependencies.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/deployment/validating-metadata.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/frontend/plugins/plugin-chatbot.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/kernel/contracts/metadata-service.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/permissions/authorization.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/permissions/capabilities.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/permissions/permission-sets.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/protocol/kernel/plugin-spec.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))
  • content/docs/ui/doc-pages.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))

⛔ 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/v18/18-0.mdx (via packageId (symbol, a field of interface NamedBaseWriteSubject))

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)
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 db6b4084699f81d63fdb2373720f6973622c9dfe → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 db6b4084699f81d63fdb2373720f6973622c9dfe → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…binding

The package-batch member cases drafted into the read-only package itself,
which the save door now refuses; they draft into its pair, declared, and
assert the same member semantics.

Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8
Co-authored-by: Claude <noreply@anthropic.com>
The control drafted into the read-only showcase itself, which the save door
now refuses naming the pair. It asserts that refusal, then writes the same
customization into the pair, declared, and pins its commit's revert through
the pair's own path and through no other.

Claude-Session: https://claude.ai/code/session_01ADHkmTBQhj1HBQsdAopiJ8
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract-review-claim: session_01ADHkmQKwKFCp1m6Pb6Q8c1 · head 29a58bb · 2026-11-02T12:47Z

The narrowing face (Clause-②: no (narrowing), #28905) is reviewed at CONTRACT_REVIEW_TIER by this seat, which dispatched the card, through an at-tier reviewer that is not the drafter. The scope is every path of the diff outside the prose faces.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 29a58bb6fd89d046f877c8dbe7c07a4b082db677
Local-runs: none
Scope: packages/metadata-protocol/src/protocol-packaged-base-refusal.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol.named-base-overlay-write.test.ts, packages/metadata-protocol/src/protocol.packageless-save-address.test.ts, packages/objectql/src/environment-overlay-email-locale-restart.test.ts, packages/objectql/src/environment-overlay-five-types.test.ts, packages/qa/dogfood/test/package-commit-revert-path-package.dogfood.test.ts, scripts/engine-double-contract.pinned.json + packages/metadata-protocol/src/protocol-packageless-overlay-migration.ts (packagelessCloseSubject), packages/metadata-protocol/src/protocol-customization-package.ts (customizationPairEnsureRefusal), packages/metadata-protocol/src/migrations/overlay-index.ts, packages/metadata-protocol/src/index.ts, packages/spec/src/kernel/override-declaration.zod.ts, packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts, packages/cli/src/utils/organization-ownership-apply.ts (carryFormWithdrawals), packages/qa/dogfood/test/customization-pair.ts, docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md (D1, D2, D11), skills/objectstack-platform/SKILL.md (ADR-0146 paragraph), scripts/check-adr-0087-registration.mjs, .changeset/28905-named-base-overlay-write-closed.md (prose face, read for ②)

Face: narrowing. The PR body and the changeset both carry Clause-②: yes (narrowing) and Metadata-protocol: narrowed at line start. Inputs read: card #28905 (body, four comments including the os-dev-report 6577504496), the #28576 card and its rulings (6568769338 arm (b), 6568273466 release timing), PR #29270 body and file list, the three-dot diff against origin/main, and the 49 check-runs on this head. Check-runs: every run is completed; none failed. Lint & Repo Gates, TypeScript Type Check, Test Core (8 shards), Build Core, Check Changeset, PR-body guards, Governed Surface Queue Guard, Spec property liveness: success. Build & E2E UI, Type Check UI, Lint UI, Test UI shards, Dogfood Verify CLI, Dogfood Regression Gate shards, Temporal Conformance, Build Docs: skipped by the path filter; their aggregate contexts report success. The dogfood matrix runs on push only (ci.yml: not on pull_request, not on merge_group), so the dogfood test edit in this diff is CI-run first after the merge; the dev's battery (os-verify-lock, 389 files passed) is the drafter's claim for it.

① Derived judgments

  1. The save door (saveMetaItemUnaudited), draft and active, refuses an authoring write naming a read-only code package onto a view, dashboard, report, translation or email_template that package ships, with 403 NOT_OVERRIDABLE. Right. The close sits after the package-less close and before the D2 gate, inside the same source === undefined && writeFace !== 'package-duplicate' guard, so the server-stated rewrites (migrate-stored, package-duplicate) keep writing the row they name. Pinned for all five types over a stub engine (17 cases) and over the real engine in objectql (10 cases, both topologies).
  2. The per-item publish and publishPackageDrafts refuse through promoteDraftForPublish, judged on promoteInto (the package the active row is written under), before repo.promoteDraft writes. Right. The batch is all-or-nothing and failed[0].code is NOT_OVERRIDABLE; pinned. A per-item publish that names no package resolves promoteInto from the draft's own binding, so a base-bound draft is closed either way.
  3. The rollback of a row bound to the base refuses, judged on rollbackPackageId (the stored row's binding; the verb has no package parameter). Right. The row is unchanged; pinned.
  4. Delete and draft discard naming the base stay open. Right, and judged in ④: the removal writes no row, and it is the one exit for a row stored before the close.
  5. The close applies only where the fix can be taken: the named package is among overlayBaseShippers (read-only shippers of the five types, the ceremony's reading of "packaged"), a pair id derives, customizationPairEnsureRefusal answers none, the base records a manifest.version, and the D2 gate admits the declared body into the pair. Right. Each refused write has an open door, the pair. A writable package, a name the base does not ship (WRITABLE_PACKAGE_REQUIRED stands), a base with no ensurable pair or no version, and an email_template locale the base does not ship all keep the write they have on main. Pinned as controls.
  6. A body carrying overrides is not this close's: a well-formed declaration naming the base from the base's own address meets the D2 gate's declared_outside_pair sentence (pinned); a malformed one (for example overrides: null) meets the schema gate's 422 INVALID_METADATA, because every one of the five type schemas spreads OverrideDeclarationFields and resolveOverlaySchema serves each of them. Neither lands a row. Right.
  7. One spelling: closedOverlayWriteClauses builds the item, base, pair, declaration and fix clauses for both closes; packagelessOverlayWriteRefusal's output is byte-identical to main (its three existing pins are unchanged in the diff and green). Right. The new sentence names the base's .custom, its ?package= address, the overrides declaration and the two open doors (the pair's declared save, a delete naming the base), with ADR-0146 D1 and D11; a 30-character base id with a 40-character name stays under the REST door's 500-character cut on each verb, pinned.
  8. Public TypeScript surface: unchanged. protocol-packaged-base-refusal.ts gains exports (namedBaseOverlayWriteRefusal, namedBaseCloseSubject, three types), but packages/metadata-protocol/src/index.ts re-exports only the judgeOverrideWrite family from that module, and exports maps . alone. Right: no Clause-② widening rides along.
  9. refusePackagedBaseOverride: comment only, no predicate change. Right.
  10. Forced test edits. (a) protocol.packageless-save-address.test.ts: the code-package case asserts the named save is refused and seeds the base-bound row directly, then runs unchanged. Right. (b) environment-overlay-email-locale-restart.test.ts: the batch-door cases draft into the pair, declared; the base ships zh-CN (the discard case serves PKG zh afterwards), so a declaration is the correct form. The member semantics pinned are unchanged row for row. Right. (c) The dogfood commit-revert control asserts the refusal on the showcase, then takes its commit in the showcase's pair through ensurePair, inPair, declaredOverride (helpers already on main). Right.
  11. scripts/engine-double-contract.pinned.json: three ledger rows (delete, findOne, update) for the new suite's fake engine, which uses the three assertEngine* doubles. Right; the gate forces the rows and is green.
  12. Not in the diff but implied: importUnmanagedPackage (writeFace: 'package-unmanaged-import') passes through the close; its target is a package installed from a manifest in the same call and ships no artifact, so overlayBaseShippers never lists it. Open as on main. Right.

② Semver level

  • Changeset: '@objectstack/metadata-protocol': minor, a BREAKING banner, a FROM → TO migration (the base-named PUT becomes the pair's declared PUT, with the draft, publish and rollback forms), and the four "Now refused / Still open / Unchanged / organization-ownership" paragraphs. The diff publishes exactly this: a write narrowing at the metadata doors, nothing else moved in a released package. minor for a BREAKING narrowing is the launch-window rule (check-changeset-no-major, green), and Metadata-protocol: narrowed requires at least minor on the moved package. The level matches.
  • Declarations: Clause-②: yes (narrowing) is right, in the PR body and the changeset. The accept set at a public door shrinks, which is a Clause-② change and the (narrowing) arm is the BREAKING one under the AGENTS.md grammar; the card's Wanted asks for this exact pair. (The dispatch brief wrote no (narrowing); that is not what the PR declares, and yes (narrowing) is the correct form.) Metadata-protocol: narrowed is right: a write an author could make is now refused, in the allowEnvironmentOverlay tier of the five types.
  • ADR-0087 disposition: not-required (already-registered packageless-overlay-write-closed). The id exists on main at the merge base (packages/spec/src/migrations/entries/semantic/18.packageless-overlay-write-closed.ts) and at HEAD, so the gate's own check for this category holds, and that entry's replacement already prescribes this change's TO (write the customization into the pair, declaring the override). Accepted. Its surface and reason text name the package-less write only; see S3.
  • Skills-docs line: checked against skills/objectstack-platform/SKILL.md's ADR-0146 paragraph on main. It places every customization in the pair with the declaration and names ?package=hotcrm as a read that serves the pair's override. Nothing there becomes false; none needed holds.

③ Boundary flags

Author: the PR user.login is objectstack-fleet[bot]; no relay note is owed.

Dev deviations:

  1. Files outside the claim's surface (objectql five-types pin, objectql email-locale test, dogfood commit-revert test, the pinned ledger). Answered: each is a forced edit through a door this change closes, or a gate-forced ledger row; each is read above (①.10, ①.11) and holds.
  2. The PR body was written once and names forced edit 1 only; the revised body (forced edits 2 and 3, ablation 6, final readings) waits at the dev's path for the seat. Answered: should-fix S5, text only; the tree is unaffected.
  3. The ADR-0087 entry's text names the package-less write only. Answered: should-fix S3, the dev's option A.
  4. Commit trailer form. Not a contract matter; noted.

Open questions:

  1. Widen the unreleased entry packageless-overlay-write-closed to name the base-named write too? Answered: A. The upgrade guide is generated from that surface and a reader should meet both closed addresses in one entry. A packages/spec edit, outside this lane: a follow-up card (S3). Not a must-fix: the changeset carries the FROM → TO itself and the entry's replacement already names the TO.
  2. os migrate organization-ownership --apply now stops with a fail-closed blocker when carryFormWithdrawals saves a withdrawal onto a view a read-only package ships, naming the served package (read in packages/cli/src/utils/organization-ownership-apply.ts: the save names servedPackageOf(served), and the blocker quotes the door's message). Answered: A, a cli card that makes the carry save into the pair, declared, when the served package is read-only (S4). The blocker is safe and the changeset tells the operator what to do, so it does not block this PR.
  3. Fold namedBaseCloseSubject and packagelessCloseSubject after migrate: os migrate packageless-overlays fails on an email template row of a locale the base does not ship, so after #28576 that row can be neither written nor migrated #28904 lands? Answered: A (S6). Read side by side today, the two agree step for step on the pair id, the ensure door, the base version and the gate; the differences are intended (the named close reads no row and accepts a named package among several shippers, the package-less close requires exactly one shipper and classifies the stored row).

Out-of-scope findings:

④ Design judgment

Against the published spec on main. ADR-0146 D1 says a read-only package's customizations land in its paired .custom; D2 says a customization of an installed package is an item of the pair declaring overrides; the supersede table retires ADR-0005's five-type tier, under which an overlay "always names the read-only package it customizes". skills/objectstack-platform/SKILL.md teaches the same and names ?package=BASE only as a read. So a write naming the base itself onto one of its own five-type items has no declared place in the spec on main; it is the second address, beside the package-less one #28576 closed, through which an undeclared customization still reached the store. The narrowing takes no declared capability away at the consumer. It makes the spec's own refusal real, and every refused write is given its spec'd door, the pair, in the same words as #28576's refusal. Right.

The doors. Read in the live tree at this head: saveMetaItemUnaudited, promoteDraftForPublish (both publish doors), rollbackMetaItemUnaudited, deleteMetaItem, revertCommit, rollbackToPackageCommit, importUnmanagedPackage, duplicatePackage, migrateStoredMetadata.

Message and status. 403 NOT_OVERRIDABLE, the code and status the sealed write and the #28576 close already answer; the same item / base / pair / declaration / fix clauses as the package-less sentence, then a rule clause that says which address is closed and names only open doors. Under 500 characters at the pinned lengths. Consistent with #28576's clause and the card's Wanted.

Findings.

Implemented-by: claude/project-thread-2isfgl
Reviewed-by: session_01ADHkmQKwKFCp1m6Pb6Q8c1

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1

维护者速读

  • 改了什么:保存、发布(单项与 publish-drafts 批量)、回滚时直接写 ?package=只读基础包 到它自带的视图、仪表盘、报表、翻译或邮件模板,现在一律 403 NOT_OVERRIDABLE,提示改写进它的 .custom 配对包并声明 overrides——与 metadata-protocol: close the package-less write onto a packaged item (PUT/DELETE, rollback, source save, reset) naming <base>.custom; Studio drops its overlay link #28576 对“不写包名”的拒绝同一句修复。删除保持开放。
  • 为什么改:ADR-0146 D1 规定只读包的定制只落在配对包里;但写基础包本身仍会落下一行未声明的定制,盖住基础包文档、不随配对包导出,迁移仪式 os migrate packageless-overlays 也看不见它。
  • 风险与回滚:仅收窄写入口,不改存量行:以前这样存下的行照常生效,可删除,或被配对包声明的覆盖压过。回滚即撤销本 PR。另:os migrate organization-ownership --apply 把公开表单撤回带到代码包视图时,现会停下报阻塞并给出配对包写法。
  • 先看:packages/metadata-protocol/src/protocol-packaged-base-refusal.ts(namedBaseCloseSubject 的放行条件与句子)、protocol.ts 中 promoteDraftForPublish 的新判定(批量发布整批中止)、protocol.named-base-overlay-write.test.ts。
  • 两分钟验证:cd packages/metadata-protocol && npx vitest run src/protocol.named-base-overlay-write.test.ts,应见 17 个用例全部通过。
  • 需要你做的:无,席位落地。

席位意见:与 diff 相符。保存、发布、回滚三道门在写库之前就拒绝,删除与草稿丢弃有意保持开放,作为旧行唯一的出口。达档契约复审 PASS(6577747588),无 must-fix。复审提的 should-fix 由本席在本 PR 合并后立卡跟进(见核对第 5 项)。

席位核对

ACCEPT · domain:server seat 5 · os-steve · session_01ADHkmQKwKFCp1m6Pb6Q8c1 · 2026-11-02T13:01Z · head 29a58bb6fd · #28905

  1. Shape: draft against main, Fixes #28905. minor changeset for @objectstack/metadata-protocol, with a BREAKING banner and FROM → TO. Clause-②: yes (narrowing), Metadata-protocol: narrowed. ADR-0087 not-required (already-registered packageless-overlay-write-closed). Not governed: +775 / -40, 9 files. The body is the dev's r1, written by this seat because the dev writes a body once.
  2. Against the order: the save, per-item publish, publishPackageDrafts and rollback doors refuse a write naming a read-only code package onto one of the five overlay types it ships. They answer 403 NOT_OVERRIDABLE with metadata-protocol: close the package-less write onto a packaged item (PUT/DELETE, rollback, source save, reset) naming <base>.custom; Studio drops its overlay link #28576's fix clause, using one spelling, and the package-less sentence is byte-identical. The close applies only where the pair fix can be taken. Delete and draft discard stay open. Read in the diff.
  3. Accepted deviations: three test files outside the claim's surface. Two are forced edits (the objectql email-locale batch doors, and the dogfood commit-revert control landed with fix(runtime)!: package commit revert and rollback act only on a commit of the package their path names #29090); the third adds the objectql five-types pin. The engine-double ledger rows were forced by check:engine-double-contract.
  4. Gates: check-runs on this head: 38 success, 15 skipped, 0 red (Lint & Repo Gates and TypeScript Type Check success). The dev reports 74/74 derived gates exit 0 (0 NOT-MEASURED), metadata-protocol 22117, objectql 10150, runtime 8329 and rest 6963 tests, and the full dogfood battery at 389 files / 3215 tests (VERDICT exit 0), with six ablations, each red then restored. The dogfood matrix does not run on pull_request / merge_group, so the dogfood edit first runs in CI after the merge. Contract review: CONTRACT_REVIEW_TIER, PASS on this head, 6577747588 (claim 6577532042).
  5. Out of scope: each is filed by this seat as its own card after the merge.
  6. Landing: pr_ready + automerge_enable through the relay now.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 13:02
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 13:02
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 838a802 Nov 2, 2026
54 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-2isfgl branch November 2, 2026 13:38
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/l tests tooling

Projects

None yet

1 participant