Skip to content

fix(metadata-protocol)!: refuse the per-item publish and a package-less save over a binding to an uninstalled package - #27084

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-66cy4b
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/project-thread-66cy4b

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26921

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: not needed — no skills/** page teaches a save naming no package over a package-bound item or the per-item publish (git grep on skills/ for /publish and discard-drafts finds one row, the publish-drafts event in skills/objectstack-platform/references/plugin-hooks.md, which this does not change); the docs paragraph in content/docs/concepts/metadata-lifecycle.mdx is updated here

维护者速读

  • 改了什么:已经绑在"未安装包"上的旧元数据行,不再能被延续或发布:不带 ?package= 的保存(草稿和直接保存)若沿用了这种绑定,回 422 WRITABLE_PACKAGE_REQUIRED;单项发布 POST /meta/:type/:name/publish(带或不带 ?package=)若草稿会发布到这种包里,同样回 422。什么都不写,也不迁移、不删除旧行。
  • 为什么改:fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 堵住了"新建"幽灵绑定,但旧库里已有的幽灵行仍能被无包保存继续写、被单项发布推上线,上线后没有命名空间、无法卸载或导出。
  • 风险与回滚:只收紧两个请求,不改存储形状;旧幽灵行照常可读、可删(DELETE ?package=ID)、可丢草稿(discard-drafts),装上同名包后又能正常保存和发布(实测)。回滚即还原本 PR,无数据迁移。
  • 先看:packages/metadata-protocol/src/protocol.ts 里 uninstalledNamedBaseRefusal(新增的 how 参数与三种句子)、saveMetaItem 中紧跟包地址解析之后的继承分支、promoteDraftForPublish 中地址解析之后的发布分支。
  • 两分钟验证:在一个 fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 之前写过 PUT /api/v1/meta/dashboard/x?package=com.example.nosuch 的库上启动本分支,PUT /api/v1/meta/dashboard/x(不带包)与 POST /api/v1/meta/dashboard/x/publish?package=com.example.nosuch 都应回 422 WRITABLE_PACKAGE_REQUIRED,GET 读到的行不变。
  • 需要你做的:无,席位落地。

What changes

  • @objectstack/metadata-protocol — two more doors ask the one named-base rule (uninstalledNamedBaseRefusal, the helper the save door and the flow arm ask since metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708), about a binding the caller did not name but the item's stored rows give:
    • A save naming no package, draft or active. Right after the save's address is resolved (the publish-mode packagelessAddress, and for a draft save the packagelessDraftAddress the object overlay check already read, now read once for every type), the package that address lands in must be installed. Refused 422 WRITABLE_PACKAGE_REQUIRED with packageId = that id, before anything is written: "Cannot save TYPE 'NAME' without naming a package: its stored rows are bound to package 'ID', which is not installed in this deployment, and this save would extend that binding. …". It is never re-homed package-less in silence. Saves naming a package (already judged as named), the server-stated rewrites (migrate-stored, package-duplicate) and an ambiguous draft address (the repository's 409, as before) are not asked.
    • The per-item publish (promoteDraftForPublish, so POST /meta/:type/:name/publish named or not): right after the draft address is resolved, the package the draft goes live in must be installed. Refused the same way before the draft is read: "Cannot publish TYPE 'NAME' into package 'ID': no package with that id is installed in this deployment, so the TYPE would go live bound to a package that does not exist. …".
    • Same rule, envelope and code; the helper takes how: { verb?, inherited? } and only the sentence changes. The flow arm's sentence and the named-save sentence are byte for byte unchanged. Both new sentences name the way out that exists for such rows: create a package with that id, or remove the rows bound to it (a delete naming the id; discard-drafts for drafts) and save again. They stay under the REST door's 500-character client message bound for ordinary names (measured: a first draft of the save sentence was cut by truncateClientMessage).
    • ⛔ No stored row is migrated or deleted; no error code was added; packages/spec is untouched.
  • Docs: the package-first authoring paragraph in content/docs/concepts/metadata-lifecycle.mdx; changeset 26921-named-base-stored-binding.md (patch, BREAKING banner, both declaration lines, ADR-0087 not-required (no-migration-prescription)).

Why

After #26708 (PR #26920) a save or a publish-drafts naming an uninstalled package id is refused, but rows bound to such an id that a database already holds (written before that change) could still be extended or published by two doors: a save naming no package inherited the phantom binding (#11087's rule), and the per-item publish served a phantom-bound draft live. Such an item has no manifest, so no namespace, and no uninstall or export.

Measure-first (the order's stop condition on the inherit arm): a refusal does NOT strand a published phantom row. On a database written by main before #26920 (7eb613c3), with five dashboards bound to com.example.nosuch (active only, active + draft, draft only): DELETE /api/v1/meta/dashboard/NAME answered 200 and removed the active row, with ?package=com.example.nosuch too; DELETE …?state=draft&package=com.example.nosuch discarded the draft; and POST /api/v1/packages with that id answered 201, after which a save naming no package over the row answered 200. The served item read already reports deletable: true. So the arm was implemented, not handed back.

Tests

  • New packages/metadata-protocol/src/protocol.named-base-stored-binding.test.ts (16 tests). Rows are written the way they were in production: saved while the package was installed, then the package leaves the registry. Refusals (11): the per-item publish named (dashboard, page), naming none over a phantom draft, naming none over a phantom active row; a save naming no package over a phantom active row (dashboard, page × draft, active); a draft save over a lone phantom draft. Each asserts code, status, packageId, the first sentence, rows unchanged and nothing registered or inserted. Controls (5): saves and publishes over an installed package's rows, package-less items, and the delete naming the id still removing the row.

  • Red first: with protocol.ts at the base blob (c3c93949, written over and restored by hash), the file fails 11 of 16; the 5 controls pass.

  • Fixtures (each now installs the package its rows are bound to, ⛔ none widens the rule): protocol.batch-verb-driver-code.test.ts, protocol.batch-verb-driver-text.test.ts and protocol.destructive-409-face-inventory.test.ts (their registry double gains getPackage for com.acme.crm); objectql/src/protocol-sentinel-owner-draft-save-address.test.ts (installs com.qa.p / com.qa.q, as protocol-adopt-orphans-object-registry.test.ts does).

  • protocol.package-closure-gate.test.ts › "narrows NOTHING when the registry cannot produce the written package" pinned exactly the promotion this card refuses (a draft promoted after its package left the registry, unnarrowed, success: true). Replaced, not narrowed: renamed to "a promotion into a package the registry cannot produce is refused before the closure gate"; the installed leg still asserts the closure refusal, the uninstalled leg now asserts 422 WRITABLE_PACKAGE_REQUIRED.

  • Package suites (vitest, --maxWorkers=2, under the verify lock), after rebuilding @objectstack/metadata-protocol so the consumers read the changed dist/:

Counts
  • On the final head 6135db9b: @objectstack/metadata-protocol 277 files passed / 4 skipped, 21749 tests passed / 26 skipped; @objectstack/objectql 477 files, 8893 tests passed.
  • On 0842cef6 (the same source as the final head for these packages, before merging main, whose incoming commits touch none of these doors): @objectstack/runtime 8529 passed / 19 skipped; @objectstack/rest 6827 passed / 344 skipped; @objectstack/client 678 passed; @objectstack/plugin-security 5049 passed / 45 skipped; @objectstack/cli unit layer 4951 passed (the integration layer is declared to CI).
  • typecheck exit 0 on @objectstack/metadata-protocol and @objectstack/objectql.
  • First run, before the fixture fixes: metadata-protocol 30 failed in 4 files and objectql 6 failed in 1 file, every one a double or boot that never installed the package its rows were bound to (listed under Fixtures above).

Acceptance notes

Verification

Red on the base (`25a697ec`, #26920 merged) vs green on this branch
Request (no package installed under com.example.nosuch) Base This branch
PUT /meta/dashboard/ghost_dash_a?package=com.example.nosuch (control: #26708's rule) 422 422, unchanged sentence
PUT /meta/dashboard/ghost_dash_d (active row bound to the id) 200, receipt package=com.example.nosuch 422 WRITABLE_PACKAGE_REQUIRED; GET still serves label Ghost D
PUT /meta/dashboard/ghost_dash_a?mode=draft 200, draft stored bound to the id 422; GET ?state=draft answers 404 NO_DRAFT (nothing written)
POST /meta/dashboard/ghost_dash_e/publish?package=com.example.nosuch 200, draft served live 422; GET still serves the old active label Ghost E
POST /meta/dashboard/ghost_dash_c/publish (lone phantom draft, no package named) 200, served live 422; GET 404 (still not live)
POST /packages/com.example.nosuch/publish-drafts (control) 404 404
Control: POST /packages app.probe1 (201), save into it, save naming none, draft, publish ?package=app.probe1 200 ×4, rows bound to app.probe1 the same
Control: package-less plain_dash save, draft, publish 200 ×3 the same
DELETE /meta/dashboard/ghost_dash_b?package=com.example.nosuch (the way out) 200, then GET 404 the same

Inherited-save sentence as served (length 423 for these names, under the 500-character client bound): "Cannot save dashboard 'ghost_dash_d' without naming a package: its stored rows are bound to package 'com.example.nosuch', which is not installed in this deployment, and this save would extend that binding. Create a package with that id first, or remove the rows bound to it (a delete naming that package; discard-drafts for drafts) and save again, into an installed writable package or with no package. Nothing was written."

  • Measure-first, on the pre-fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 tree with the same snapshot: DELETE /meta/dashboard/ghost_dash_a (no package) 200 and GET 404; DELETE …ghost_dash_d?package=com.example.nosuch 200; DELETE …ghost_dash_b?state=draft&package=com.example.nosuch 200 and the draft read 404 NO_DRAFT; POST /packages with id com.example.nosuch 201, then PUT /meta/dashboard/ghost_dash_e naming none 200 with the row still bound to that (now installed) id. The two doors this PR closes are not on any of those paths.

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on the final head 6135db9b: 101 commands, each run with its exit code captured before any pipe, all 101 exit 0. --ran reconciliation: "101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN".

The two first-run reds and what cleared them
  • check:engine-double-contract exit 1: the new test file's engine double answers delete, findOne and update the ledger did not record ("New pinned coverage is GOOD … Run --write"). Recorded with node scripts/check-engine-double-contract.mjs --write (commit 6135db9b), then exit 0.
  • check-plugin-teardown-shape.mjs --self-test exit 3: the shallow clone lacked its positive-control commit; after git fetch --depth=1 origin 621a4876…, exit 0. Not a finding.
  • The ledger commit moved the derived set by 8 families (scripts/** now in the change set); those 8 were run on 6135db9b, all exit 0.
  • Full dogfood once (pnpm --filter @objectstack/dogfood test under the verify lock, after a full build, 116/116 tasks) on 16162380 (this branch after merging main at 3230bdbf): VERDICT command-exit 0, Test Files 346 passed / 3 skipped, Tests 2794 passed / 20 skipped. The only commit after it, 6135db9b, adds three rows to scripts/engine-double-contract.pinned.json, which no dogfood test reads.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec untouched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (Root ESLint --no-inline-config on the 7 touched TS files: 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: 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 Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5mdd4LycvwUCqLDF7X

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

5 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), saveMetaItem (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/services-checklist.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/permissions/authorization.mdx (via saveMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

⛔ 2 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/ 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 55 of 207 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 — 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 cbb3a1a1a5f858d2f34fcf874fc631b158fae9d1 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 cbb3a1a1a5f858d2f34fcf874fc631b158fae9d1 → 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

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-28T05:06Z · head 6135db9b · #26921

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

  1. Shape:
    • A draft against main, assignee os-project-manager. The body carries Fixes #26921, with Clause-②: yes (narrowing), Metadata-protocol: none and the Skills-docs: line, all line-initial.
    • The changeset is a patch, with ADR-0087 not required (no migration prescription). The docs paragraph in metadata-lifecycle.mdx is updated.
    • check-governed-merges --pr 27084 reads NOT governed, 507 changed lines.
    • The claim (6458205156) names this PR's branch.
  2. Against triage (6454567658) and the order:
    • Measure first held. On a pre-fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 database, a published phantom row can still be removed: a DELETE with or without ?package=, and a draft discard. It becomes editable again once a package with that id is created. The inherit arm therefore does not strand a row, and both arms landed.
    • One rule. Both doors ask the one uninstalledNamedBaseRefusal. It gains a how { verb, inherited } parameter that changes only the sentence. The answer is 422 WRITABLE_PACKAGE_REQUIRED, with no new code and no spec change.
    • The inherited and publish sentences name the real ways out: create the package, or delete or discard the bound rows. They do not offer "save without a package", because that save inherits the binding.
    • The per-item publish is refused before the draft is read, named or not.
    • The inherited save is refused for a publish-mode save through its resolved package and for a draft save through its draft address. An ambiguous address is still left to the repository's 409.
    • A save that names a package, and the server-stated rewrites, are unchanged.
    • The draft-address read moved out of the object-only branch so that both gates read one address. A package-less draft save now makes that one read for every type.
    • Nothing is migrated or deleted.
  3. Red first (the dev's):
  4. Tests:
    • On the head: metadata-protocol 21,749 passed and objectql 8,893 passed.
    • On the same source: runtime 8,529, rest 6,827, client 678, plugin-security 5,049 and cli 4,951.
    • Fixture fallout was fixed by installing packages; no rule was widened.
    • Both type-checks exit 0.
  5. Dogfood (owed by the narrowing): 346 files and 2,794 tests passed on 16162380. The only later commit adds three rows to the engine-double ledger JSON, which no dogfood test reads.
  6. Gates:
    • Locally 101 of 101 derived commands exit 0, with 0 NOT-MEASURED.
    • CI on head 6135db9b: 37 success, 11 skipped, 0 red.
  7. Overlap: git merge-tree reports no conflict against main fbc16563.
  8. Noted, not filed: Studio shows "Package not found" for a phantom id, so the named ways out are REST or SDK acts. A phantom id that the install door refuses as a package id (for example, one with underscores) cannot be brought back by creating the package, but its rows can still be deleted or discarded.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6135db9b7195db854bb91580bdf51952e60aaf8d
Local-runs: none
Scope: packages/metadata-protocol/src/protocol.ts, scripts/engine-double-contract.pinned.json, packages/metadata-protocol/src/protocol.named-base-stored-binding.test.ts, packages/metadata-protocol/src/protocol.package-closure-gate.test.ts, packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts, packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts, packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts, packages/objectql/src/protocol-sentinel-owner-draft-save-address.test.ts + packages/metadata-protocol/src/protocol-write-package-scope.ts (resolveWritePackageScope, the installed-or-not predicate the rule asks), packages/metadata-protocol/src/protocol-lock-version-gates.ts (packagelessDraftAddress, the into the inherited arm judges), scripts/check-engine-double-contract.mjs (the gate the ledger rows feed), packages/rest/src/error-response.ts (CLIENT_MESSAGE_MAX = 500), .changeset/26708-named-base-every-type.md on origin/main (the precedent declaration pair)

Face: narrowing (Clause-②: yes (narrowing) on the PR body and the changeset). Net diff read against merge-base 3230bdbf (head vs the merge commit 6845035c's first parent d7f76b6d): 10 files, +473/-34, byte-equal to the PR's file list. Card #26921 read in full: body, triage 6454567658, claim 6458205156, os-dev-report 6460881887, landed 6462047952; PR body, ACCEPT 6461156816, docs-drift bot comment. Check-runs on the head: 50, 39 success, 11 skipped (all UI / opt-in / live-DB families with no path hit), 0 failure; the eight required contexts, Dogfood Regression Gate and the engine-double gate's job are among the successes. This record was asked after the merge (landed 2026-10-28T06:03Z); it is the record of record for the head.

① Derived judgments

Accept-set changes the diff implies, each judged against the triage ruling (per-item publish refuses with the ONE rule; inherit arm leans refuse after measure-first; no migration, no deletion, no second copy, no new code, no packages/spec):

  1. PUT /meta/:type/:name naming no package, publish mode, over active rows bound to one package id the registry does not hold: 200 → 422 WRITABLE_PACKAGE_REQUIRED with packageId = that id, before any write. Right. saveMetaItem resolves the address once (packagelessAddress, rewriting request.packageId), and the new block reads that resolved id under namedNoPackage (captured before the rewrite), so the ask is about the inherited binding only; a save that names a package was already judged as named two gates up; source set (migrate-stored) and writeFace: 'package-duplicate' are exempted exactly as the named ask exempts them.
  2. The same save in draft mode, over an active row bound to such an id, or over a lone draft bound to it: 200 → 422. Right. packagelessDraftAddress answers into = own for an item bound to one package (draft present or not, lines 478-480 of the moved body) and into = key for the lone-draft case (line 486), so the judged id is the package the draft would be written under. An ambiguous address passes null to the rule and falls through to the repository's 409, unchanged.
  3. POST /meta/:type/:name/publish, named (?package=ID) or not, whose promotion lands in such an id: 200 → 422, before the draft is read. Right. The ask is on promoteInto (the caller's key, or the address's into), after the lock refusal and the address refusal keep their order. publishPackageDrafts reaches the same method with packageId stated; its door already refuses an uninstalled id, and an installed id passes resolveWritePackageScope, so the batch publish's accept set does not move.
  4. The sys_metadata sentinel and a null binding return null from the helper first; package-less items and items bound to installed packages are untouched. Right, and pinned by the 5 controls in the new file.
  5. Fail direction on a registry with no getPackage: the per-item publish naming a package, or inheriting one, is now refused on such a double, the closed direction metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708 took for the save door. Right, and the three metadata-protocol fixtures plus the objectql boot now install the package their rows bind to; no fixture widens the rule (each getPackage answers only the seeded id). Test Core 8/8 green says no other double in the repo was left behind.
  6. protocol.package-closure-gate.test.ts: the pin "narrows NOTHING when the registry cannot produce the written package" asserted exactly the promotion this card refuses (success: true after the package left the registry). Replacing it with the 422 assertion and keeping the installed leg's closure refusal as the discriminating control is right: the state it pinned is no longer reachable through a promotion. Minor, not blocking: resolveWritePackageScope's docblock still lists "the registry cannot produce the package" as a way the closure gate sees the whole collection; that sentence is still true of the function and now describes no promotion path.
  7. scripts/engine-double-contract.pinned.json: three rows (delete, findOne, update, pinned 1 each) for the new test file, whose double routes those three verbs through assertEngineDeleteDispatch / assertEngineFindOnePredicate / assertEngineUpdateDispatch from @objectstack/metadata-core (not objectql, which depends on this package). The gate's SLICES are exactly those three verbs; the rows are the RETAINED enumeration the gate's header asks for a newly pinned double. Right; the gate's job is green on the head.
  8. Sentences: the helper's third (named-save) sentence and the flow arm are byte-unchanged (diff read). The two new sentences measure 423 (inherited) and 422 (publish) characters for the PR's own names and 478 / 482 with a 14-character type and 40-character name and id, under the REST door's CLIENT_MESSAGE_MAX = 500, as the PR claims. They name only ways out that exist (create the package; delete naming the id; discard-drafts), never "save without a package", which would inherit the binding.
  9. Public surface: uninstalledNamedBaseRefusal is private and its new how parameter defaults to {}, so every existing caller is source-compatible; no export, no Zod face, no error code, no packages/spec file moves (check:api-surface green). The inherited draft-address read now runs for every type's package-less draft save, two sys_metadata reads the repository's resolveWriteHead already makes for the same save; declared in the PR's Acceptance notes, a cost, not a contract change.
  10. Measure-first stop condition (triage): the PR records on a pre-fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 snapshot that a published phantom row is still deletable (DELETE with and without ?package=), its draft discardable, and the item editable again after POST /packages with that id (201). The new file pins the delete leg. The arm was implemented, not handed back, as the ruling allows.

② Semver level

.changeset/26921-named-base-stored-binding.md: '@objectstack/metadata-protocol': patch, BREAKING banner, Clause-②: yes (narrowing), Metadata-protocol: none, one ADR-0087 marker not-required (no-migration-prescription), and the FROM → TO remedy (create the package, or delete / discard-drafts and save again). The level matches what the diff publishes: the only package whose source moves is @objectstack/metadata-protocol (objectql and scripts change tests and a ledger, which publish nothing); the kind is none, since what is refused is a request's binding and no key, value or metadata type an author writes changes, so none takes patch, and a narrowing non-protocol change ships patch with its banner and disposition. This is the pair the precedent .changeset/26708-named-base-every-type.md on origin/main carries for the same rule one door over. The Clause-②: line is yes (narrowing) on the PR body and the changeset, line-initial in both, and the face it declares is the one the diff has: two 200s become 422s. Check Changeset and Lint & Repo Gates (which carries check:adr-0087-registration) are green on the head. Match.

③ Boundary flags

  • open_questions: [] in the os-dev-report. None to answer.
  • Dev note 1 (no class): Studio shows "Package not found" for a phantom id, so the ways out the new sentences name are REST / SDK acts. Answered: carried in the PR's Acceptance notes, inherited from metadata: the save door and the package publish door accept a package id that is not installed (object, dashboard, page …), storing and even publishing items into a phantom package; Studio renders a writable builder for it #26708's own note, and the triage asked for refusal naming the way out. Not a FAIL. The seat should weigh a Studio follow-up card: a Studio author on a pre-fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920 database now meets a 422 whose prescription Studio cannot perform.
  • Dev note 2 (no class): a phantom id the install door refuses as a package id cannot be brought back by creating the package; its rows can still be deleted and discarded. Answered: the sentence's "or remove the rows bound to it" covers it; note-only is right.
  • Dev deviation: commits carry Claude-Session plus Co-authored-by: Claude instead of the harness reminder's model-named line. Answered: that pair is the form AGENTS.md prescribes and the pre-push hook enforces, so this is compliance, not a deviation; the merge commit 16162380 carries no trailer, which is ordinary for a git merge origin/main commit. Nothing owed.
  • Skills-docs line: present on the PR body; git grep on skills/ for the per-item publish and the package-less save over a bound item finds no teaching page beyond the publish-drafts event row, which this change does not alter. Held.
  • Governed surfaces: none of the ten paths is in GOVERNED_SURFACES (register read); Governed Surface Queue Guard green; 507 changed lines, under 3,000.

Implemented-by: claude/project-thread-66cy4b
Reviewed-by: session_0186vfjHHMsRXC9EUT9hTFMo

VERDICT: PASS


Generated by Claude Code

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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants