Skip to content

feat(spec)!: an install through POST /api/v1/packages declares manifest.scope project only, so an authored install cannot lock itself - #24160

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24062-authored-install-scope
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24062-authored-install-scope

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24062

Clause-②: no (narrowing)

An install through POST /api/v1/packages may now declare manifest.scope project only, or leave it out. cloud and system are refused at parse time with 400 / VALIDATION_ERROR, on both body forms and on either overwrite spelling, and the refusal says what to write instead. So an authored install can no longer lock itself. ManifestSchema is unchanged for every other producer.

Session: session_015p3xNTnaiNHYkAHoQ4y5Cg (the os-dev cloud run dispatched by the domain:spec#2 seat, claim 6230547268). Base 9f024d8a2c, head b78d15a5.

What changed

  • packages/spec/src/api/package-api.zod.ts
    • New export PackageInstallManifestSchema (plus the types PackageInstallManifest and PackageInstallManifestParsed). It is ManifestSchema.extend({ scope }) with scope: z.enum(['project'], { error }).default('project'). Every other key, the strict close, its unknown-key prescription and the retired-key tombstones ride the .extend() unchanged; the authorable-surface shard lists the same key set, tombstones included.
    • New export packageInstallScopeRefusal(input): the one refusal sentence. It names the value sent, why cloud / system lock the package, and the remedy: "Remove scope (it defaults to project), or write scope: 'project'."
    • PackageInstallRequestSchema.manifest and the bare branch of PackageInstallBodySchema both now use PackageInstallManifestSchema. The docblocks that named ManifestSchema for those two positions now name the new schema.
    • The overwrite describe gains: "an id the deployment ships read-only (code-booted, or scope cloud / system) is refused 422 WRITABLE_PACKAGE_REQUIRED". I re-read PR fix(runtime)!: POST /packages refuses an overwrite over a read-only package with 422 WRITABLE_PACKAGE_REQUIRED #24055's door on main: requireWritablePackage runs after the 409, for both overwrite spellings, at packages.ts (the readOnly leg).
  • packages/runtime/src/domains/packages.ts: one line plus its comment and import, a deviation from the dispatch's "no runtime source change" expectation, measured and reported below. installBodyRefusal re-parses the bare form through PackageInstallManifestSchema, the union's own bare branch, instead of ManifestSchema.
  • Tests: spec pins in package-api.test.ts, and the door pins as §9 of packages-install-body-contract.test.ts.
  • Generated outputs, regenerated by the generators only: the api shards of api-surface, authorable-surface, authorable-defaults, json-schema.manifest, declaration-map and export-origins; content/docs/references/api/package-api.mdx and references/index.mdx. Plus one hand-edited ledger, dropped-refinements.baseline.json: the build printed the entry for the new published schema api/PackageInstallManifest (the same navigationContributions site api/PackageInstallRequest already declares), and the header totals 235 to 236 and 766 to 767, which scripts/dropped-refinements.test.ts asserts. Delta vs main: 3 exports added, 0 removed.
  • .changeset/24062-authored-install-scope-project.md: @objectstack/spec minor (BREAKING narrowing, launch-window convention, PR fix(runtime)!: POST /packages refuses an overwrite over a read-only package with 422 WRITABLE_PACKAGE_REQUIRED #24055's 62f959dd62 precedent) and @objectstack/runtime patch. It carries FROM / TO, the Clause-② line, and the ADR-0087 disposition not-required (no-migration-prescription). The only conversion that could exist (rewrite cloud / system to project) is the silent rewrite this card refuses.

Accept set, before and after

Body Before After
wrapped { manifest }, scope: 'cloud' or 'system' 201, authored row stored with that scope 400 VALIDATION_ERROR, nothing installed; issue invalid_value at manifest.scope
bare manifest, scope: 'cloud' or 'system' 201 400 VALIDATION_ERROR, nothing installed; issue at scope
either form with overwrite: true or ?overwrite=true, platform scope 201 on a fresh id; over an authored project row it re-locked that row 400, ahead of the 409 and the read-only gate; an existing row keeps its manifest
scope: 'project', either form 201 201
no scope, either form 201, stored as sent (no scope key) 201, stored as sent: the door still stores what it was sent, so absence stays absent
ManifestSchema.safeParse with cloud / system / project accepted accepted, unchanged

Measured: the door needed one line, and why I made it rather than stopping

A2 assumed narrowing the declaration makes the door refuse with no runtime change. That holds for the verdict and fails for the sentence. Probe on the built dist, before the runtime line:

cloud  wrapped false bare false
 union bare issues: [["invalid_union",[],"Invalid input"]]
 ManifestSchema re-parse: true | narrowed re-parse: manifest.scope "cloud" is not a scope a package installed through POST /api/v1/packages ma…
 wrapped re-parse: [["invalid_value",["manifest","scope"]]]

zod reports the failed union as one invalid_union, and installBodyRefusal locates the failure by re-parsing the form the caller wrote. For the bare form it re-parsed with ManifestSchema, which admits cloud. That re-parse passed, so the door fell back to the union's issues, and the bare-form refusal would have read (body): Invalid input: a 400 that does not say what to write. The door's own header says the re-read schemas are "the declarations @objectstack/spec publishes for this door; nothing here restates them", and ManifestSchema is no longer that branch. So the fix re-reads the branch the union actually carries. It adds no door-side verdict: which bodies are refused is still the declaration's decision alone. The ablation below shows the line is load-bearing. If the seat would rather hold the runtime file out, the alternative is the bare-form refusal without a prescription.

Producers measured (A3)

git grep on 9f024d8a2c for every sender of POST /api/v1/packages and every parser of PackageInstallRequestSchema / PackageInstallBodySchema outside packages/spec:

  • @objectstack/client packages.install(manifest, options) forwards the caller's manifest, and sets no scope of its own.
  • packages/runtime/src/domains/packages.ts is the door itself.
  • packages/client/src/readme-package-install-example.test.ts and packages/runtime/src/domains/packages-install-body-contract.test.ts are the two test parsers. Neither fixture carries a platform scope; both stay green unchanged.
  • The CLI (packages/cli/src): no sender of this door.
  • metadata-protocol duplicatePackage calls installPackage in-process, not over this door, and strips scope from the copy already ("the copy is scope-less"): the same rule this card declares.
  • The platform's scope: 'system' / 'cloud' manifests (plugin-auth, plugin-security, organizations, the service plugins, the apps) reach the registry by code boot (registerApp / registry.installPackage(…, 'code')), never through these schemas.
  • Studio (../objectui): NOT MEASURED. The sibling checkout is not in this container and packages/console/dist is absent. Studio creates bases through this door. If it ever sent a platform scope, the new 400 names the fix.

No first-party producer sends cloud / system through these schemas, so the narrowing breaks nothing first-party.

Fixtures triaged (A9)

None needed re-spelling. Every test that installs a scope: 'system' / 'cloud' package seeds the registry directly and never passes through the door or the narrowed schemas: packages-readonly-gate, packages-writable-verdict, packages-serializable-response, packages-install-overwrite-readonly (its authored cloud-scoped row is exactly the already-locked shape, still meaningful), and metadata-protocol's get-meta-items-package-writable. §8's existing scope: 'tenant' row still answers 400 naming manifest.scope.

Rows that already locked themselves (A7): a documented operator delete, no code remedy

  • Measured: service-package start() re-installs every sys_packages row as authored with its stored manifest (packages/services/service-package/src/index.ts, the hydration loop's registry.installPackage(…, 'authored')). isWritablePackage reads manifest.scope off the registry row. So a row stored with cloud / system before this PR is still read-only after it and after every restart. DELETE, PATCH …/disable and the overwrite door keep refusing it 422; this PR changes nothing for such a row.
  • Recommended remedy: the documented operator delete, written into the changeset: DELETE FROM sys_packages WHERE id = 'PACKAGE_ID' on the environment's database, then restart. No API door can do it, by ADR-0070 D2's own design: there is no isSystem escape hatch.
  • Why not a startup re-scope: it is a stored-row rewrite at boot, the silent-rewrite class the card's filing and its review both advise against. It would also be a code remedy outside this card's file surface (a stop-and-report). And no evidence of a real affected row exists: the defect was found by a deleted probe, and no first-party producer sends the scope. If the seat or the maintainer wants a code remedy, it is a separate card on service-package hydration.
  • Not measured: what happens to sys_metadata items such a package owned once its row is deleted.

Tests and evidence (head b78d15a5)

  • Spec build: VERDICT command-exit 0.
  • Spec vitest --project local --maxWorkers=2, full: 690/692 files on the first run. The 2 red were generated-ledger freshness (dropped-refinements totals; export-origins missing the two new exports), both fixed by regeneration; then scripts/dropped-refinements.test.ts + scripts/export-origins.test.ts: 51/51 passed. src/api/package-api.test.ts + package-install-one-authority.test.ts: 105/105.
  • check:generated: "All 14 generated artifacts are up to date".
  • Spec typecheck and runtime typecheck: VERDICT command-exit 0.
  • Runtime, all 28 src/domains/packages*.test.ts: 28 files, 559 tests passed.
  • Client readme-package-install-example, client.test, return-type-precision: 3 files, 220 tests passed.
  • Ablation 1 (runtime line), via scripts/ablation-replace.mjs: the bare-branch re-parse was put back to ManifestSchema. Result: exactly the 4 BARE §9 cases red ("4 failed / 40 passed"), all WRAPPED cases green. Restore: blob equals HEAD 3168f2e89ab3, git diff HEAD empty.
  • Ablation 2 (spec narrowing, reverse verification through dist): the enum was widened back to cloud / system / project with a marker value. ablation-dist-preflight: "marker present in 10 built files". Runtime contract: 11 failed / 33 passed (every §9 refusal, both forms, both overwrite spellings, the existing-row overwrite); spec: 2 failed / 90 passed. Restored, rebuilt, --absent preflight exit 0, "working tree clean against HEAD".
  • check-adr-0087-registration: one declared-breaking changeset, [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription), exit 0. check-changeset-no-major: "This diff introduces no major bump", exit 0.
  • node scripts/pm/check-governed-merges.mjs --branch claude/issue-24062-authored-install-scope: "0 of 14 path(s) hit the register … NOT governed"; 412 changed lines. No skills/** path moved.
  • Derived gate families (dispatch-gates --commands, 110): all run; 108 exit 0. Two exit 3, PREREQUISITE NOT MET, read as NOT MEASURED and left to CI: check:skill-examples (packages/client-react/dist not built here) and check:dual-build-cjs-loads (dists outside this closure). --ran reconciliation: "110 derived famil(ies) accounted for — 108 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3)". check-plugin-teardown-shape --self-test first refused on the shallow clone; after fetching its pinned fixture commit: "48 cases pass".
  • main moved 3 commits past the base (135e19e20, 1fee13d4c, c40506aac). None touches a path in this diff, so I did not merge them; CI judges the merge ref.

Acceptance notes


Generated by Claude Code

…; the door locates the bare-form refusal through the union's own branch

Claude-Session: https://claude.ai/code/session_015p3xNTnaiNHYkAHoQ4y5Cg
Co-authored-by: Claude <noreply@anthropic.com>
…d the self-locked-row remedy

Claude-Session: https://claude.ai/code/session_015p3xNTnaiNHYkAHoQ4y5Cg
Co-authored-by: Claude <noreply@anthropic.com>
… reference docs and the dropped-refinements totals for PackageInstallManifestSchema

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 8 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/authorable-defaults/api.json, packages/spec/authorable-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in a comment on a changed line; a path literal in packageInstallScopeRefusal; a path literal on a changed line; bridged from symbol PackageInstallBodySchema — its route source's handler names it))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

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

Coarse fallback — 148 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 c40506aac02df70aad25588a19a629fa2efa4c62 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 c40506aac02df70aad25588a19a629fa2efa4c62 → 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:spec seat 2 · huangyiirene · session session_01HSDGREvqxQUt7K7pjHXMyC · 2026-10-17T07:54Z · head b78d15a5b13af7e6de0a5da6e0c45d4d5256a972

The seat's own review at TIER_DEFAULT of the parts outside the contract-review scope. The CONTRACT_REVIEW_TIER record for the narrowed declaration follows as its own comment.

item reading
shape Draft, base main. First line Fixes #24062; Clause-②: no (narrowing) line-start. No other closing keyword sits next to an issue number.
file list 14 files, +384 / −28: package-api.zod.ts and its test, the runtime door's contract test (§9 door pins) and the one packages.ts line, the changeset, the hand-edited dropped-refinements.baseline.json (+1 schema entry, totals 235 → 236 / 766 → 767), and eight generated outputs. No content/docs/releases/ change. ManifestSchema is untouched.
premise The dev re-measured the 201 for scope: 'cloud' / 'system' on both body forms before the change; the before/after table in the body matches the pins.
changeset @objectstack/spec minor (BREAKING narrowing, the launch-window convention PR #24055 used on this door) and @objectstack/runtime patch; Clause-②: no (narrowing); ADR-0087 not-required (no-migration-prescription). Sentences checked against the diff: FROM / TO / Unchanged rows; "The fix for an integrator: remove scope … or write scope: 'project'" (the refusal text says the same); the self-locked-rows paragraph (matches the measured hydration path).
gates Check-runs on the head: 41 total, 36 success, 5 skipped, 0 failures; Lint & Repo Gates and TypeScript Type Check success. check-expected-skips.mjs --pr 24160: all 5 skips rostered.
governed / size check-governed-merges.mjs --pr 24160: 0 of 14 paths governed; 412 changed lines, under 3000. Ordinary queue landing.

The dev's open question, answered by the seat: A — keep the one-line packages.ts change. The claim (6230547268) expected no runtime source change and called one a breach to report; the dev reported it, measured why (the bare-form re-parse through ManifestSchema passed, so the refusal lost its sentence), and proved it load-bearing by ablation (4 bare-form §9 cases red without it). It adds no door-side verdict: which bodies are refused is still the declaration's alone, and the door's own header says its re-read schemas are the spec's published branches, which ManifestSchema no longer is for the bare form. The claim's file surface is amended accordingly to include that one line of packages/runtime/src/domains/packages.ts.

Producer the dev could not measure, measured here: Studio (objectui) at the console pin 5e15928b4631. packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx:336 sends a new base with deliberately no scope ({ version: '0.1.0', type: 'app' }, spread into POST /api/v1/packages { manifest } at :473–:483), and PackagesPage.tsx:196 reads the server's served writable verdict, never a guess from scope. So no first-party producer sends cloud / system through these schemas, and Studio's create path is unaffected. (The dialog's comment at :337 still says scope: 'project' marks a read-only code package; that is a stale objectui comment the served verdict has superseded. Noted, not filed.)

Out of scope, where each goes:


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b78d15a5b13af7e6de0a5da6e0c45d4d5256a972
Local-runs: none
Scope: packages/spec/src/api/package-api.zod.ts + packages/spec/src/kernel/manifest.zod.ts, packages/runtime/src/domains/packages.ts, packages/runtime/src/domains/packages-install-body-contract.test.ts, packages/spec/src/api/package-api.test.ts, packages/metadata-protocol/src/package-writability.ts, packages/services/service-package/src/index.ts, .changeset/24062-authored-install-scope-project.md, packages/spec/dropped-refinements.baseline.json, the six regenerated api shards (api-surface, authorable-surface, authorable-defaults, declaration-map, export-origins, json-schema.manifest) and content/docs/references/api/package-api.mdx, scripts/check-adr-0087-registration.mjs, docs/adr/0087-metadata-protocol-upgrade-contract.md, docs/adr/0070-package-first-authoring.md, the Check Changeset job in .github/workflows/pr-automation.yml, and in objectui at the pinned 5e15928b46: packages/app-shell/src/views/metadata-admin/PackageFormDialog.tsx and package-schema.ts. Remainder (lane seat's ACCEPT at TIER_DEFAULT): content/docs/references/index.mdx, a regenerated index.

Inputs read: card #24062's body and all five comments (grade 6226980977, routing dissent 6228573973, retriage 6229341285, claim 6230547268, os-dev-report 6231085714); PR #24160's body, its 14-file list and the net diff against main (merge base 9f024d8a2c); the 45 check-runs on the head: 37 success, 8 skipped, 0 failure. The two Check Changeset runs (check-empty-changeset and check-adr-0087-registration against the merge base, the launch-window guard by the same job's own header), Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Type Check (workspace, source gates, consumer gates, debt ledger), Test Core 1 to 6, Build Core and Build Docs are all success.

① Derived judgments

  1. The new exports — right. PackageInstallManifestSchema is a lazySchema thunk over ManifestSchema.extend({ scope }). strictObject() (packages/spec/src/shared/strict-object.ts) returns a ZodClosedObject whose def carries the unknown-key prescription as its error map; zod 4's extend clones that def (constructor and error map ride along, and it refuses an object that carries checks), so the strict close and the prescription survive, and the ledger proves no refinement moved: api/PackageInstallManifest has exactly the one dropped site kernel/Manifest has. The authorable-surface shard lists the identical 26-key set, the four [RETIRED] tombstones included; authorable-defaults records scope = "project" and defaultDatasource = "default", the same defaults ManifestSchema carries. api-surface, export-origins and declaration-map add four entries (PackageInstallManifest type, PackageInstallManifestParsed type, PackageInstallManifestSchema const, packageInstallScopeRefusal function) and remove none; the PR body's "3 exports added" undercounts by one, prose only, the shard is right.
  2. The narrowed accept set — right on both forms and on PackageInstallRequestSchema. The wrapped branch's manifest and the union's bare branch both read the new schema; the union's disjointness argument (a strict object with no manifest key) still holds because the extend keeps the strict close. Pinned on both sides: the spec test asserts one invalid_value issue at manifest.scope (wrapped) or scope (bare) whose message names the value and scope: 'project'; the door's §9 asserts five forms times two scopes answer 400 / VALIDATION_ERROR through expectRefused with nothing installed, that an overwrite of an authored project row declaring cloud leaves its manifest intact, and that project and an absent scope still install 201.
  3. ManifestSchema and every other producer untouched — right. git diff origin/main...head -- packages/spec/src/kernel/ is empty; scope: z.enum(['cloud', 'system', 'project']).default('project') at manifest.zod.ts:608 is unchanged; the spec test pins all three scopes green on ManifestSchema. Platform packages reach the registry by registerApp / code boot, never through these schemas, and metadata-protocol's duplicatePackage already strips scope from the copy.
  4. No first-party producer breaks. @objectstack/client packages.install sets no scope and its README fixture carries none; the CLI sends nothing to this door; the runtime door is the consumer. Studio, which the dev reported NOT MEASURED, is measured here at the pinned objectui 5e15928b46: PackageFormDialog.tsx POSTs /api/v1/packages { manifest } with a create draft of { version: '0.1.0', type: 'app' } and deliberately no scope (its own comment says so), so the default create path is unbroken. package-schema.ts exposes scope as an editable create-mode field whose options come from z.toJSONSchema(ManifestSchema), so a user can still pick cloud or system — exactly the card's self-lock path — and now gets the 400 with the remedy, which apiJson surfaces from the ADR-0112 envelope. That is the right behaviour; the form offering two values the door refuses is an objectui-side follow-up (derive the create form's scope options from PackageInstallManifestSchema), named here for the adopting seat, not a defect of this PR.
  5. Every published sentence is true of the platform. packageInstallScopeRefusal: cloud and system are read-only scopes — READ_ONLY_PACKAGE_SCOPES = ['system', 'cloud'], isWritablePackage reads the stored manifest.scope, and requireWritablePackage answers 422 WRITABLE_PACKAGE_REQUIRED on delete, disable, publish, revert and the install door's overwrite leg; "remove scope (it defaults to project)" is true of the schema and of the predicate, which treats an absent scope as writable. The overwrite describe ("an id the deployment ships read-only (code-booted, or scope cloud / system) is refused 422 WRITABLE_PACKAGE_REQUIRED") is true: requireWritablePackage(..., 'overwrite') runs after the 409 for both overwrite spellings, and the predicate returns false for engine.manifests.has(id) as for a stored read-only scope; it renders into package-api.mdx line 135. The "scope is project" describes are true of the parse, and the changeset's "an absent scope stays absent" is true of the door, which hands on body.manifest || body, the raw manifest, never the parsed copy (the "parse is a GATE, not a normaliser" comment); the §9 control pins it.
  6. The runtime line adds no door-side verdict. installBodyRefusal is reached only after PackageInstallBodySchema.safeParse failed; the re-parse decides which branch's issues to word, never whether to refuse. The door's own rule — the re-read schemas are the branches @objectstack/spec publishes for this door — is upheld, since ManifestSchema is no longer the union's bare branch. The §9 BARE cases pin the sentence's subjects (scope, the value sent, scope: 'project').
  7. Generated artefacts match the source. The six shards, package-api.mdx and index.mdx are regenerated (check:generated rides Lint & Repo Gates, success); the dropped-refinements header totals 235 to 236 and 766 to 767 with one new row are what the shrink-only ledger requires of an added published schema, and no site is removed.

② Semver level

  • @objectstack/spec minor — matches. Four new exports are at least minor, and the BREAKING narrowing ships as minor under the launch-window convention: check-changeset-no-major refuses major, and the Check Changeset job's own header records the maintainer's ruling (2026-09-04, decision batch [WIP] Add query enhancements and advanced validation features #35, on finding(changeset): two independent contract reviews read the repo's own history to opposite bumps for "add an exported symbol to a published index" #15294) that breaking-ness is carried by the BREAKING banner plus the ADR-0087 disposition, not by the level. Precedent on the same door: PR fix(runtime)!: POST /packages refuses an overwrite over a read-only package with 422 WRITABLE_PACKAGE_REQUIRED #24055's changeset at 62f959dd62 (fix(runtime)!:, minor, Clause-②: no (narrowing), not-required (no-migration-prescription)).
  • @objectstack/runtime patch — matches. The runtime's own diff is the bare-form refusal sentence, a fix in a released package, and it moves no public surface of its own; the door's narrowed answer is the spec declaration's, inherited through the dependency, and the shared changeset body carries the BREAKING banner, FROM / TO and the integrator fix into the runtime CHANGELOG as well. The Check Changeset level axis read the PR body's declaration and passed.
  • Clause-②: no (narrowing) on the PR body and in the changeset — right. Nothing in the diff widens an accept set; the install declaration's accept set narrows; the gate read the arm as signal (4) beside the ! and the banner: [BREAKING+bang+clause-②-narrowing].
  • ADR-0087 disposition not-required (no-migration-prescription) — right. On the ADR's own distinction (its addendum: the ledger is the data source for objectstack migrate meta, and a prescription for a source-code consumer is not the ledger's business), the refused value is one a caller sends in a request body, no stored shape moves, and registered would have to name a conversion that does not exist; the only conversion that could (rewrite stored cloud / system to project at hydration) is refused on purpose, see ③ item 2. The other five categories are closed on facts as the marker itself says. Observation, not a flag: the changeset spells its FROM / TO as two bold bullets with no arrow, a spelling outside the detector's branch-1 label convention; the category holds on the distinction above, not on the detector's silence, and re-spelling it to trip the detector would force a registered entry that lies.

③ Boundary flags

  1. Open question, keep the one-line runtime change (A) or drop it (B) — A, with the seat. Judged in ① item 6: the line adds no verdict. B would leave the bare form answering a 400 that reads (body): Invalid input, against the door's own "surface the declaration's sentence" rule and the repository's refuse-with-a-prescription principle. The dev's ablation 1 (exactly the four BARE §9 cases red with ManifestSchema restored) is consistent with the code read. The claim's "no runtime source change expected; one is a breach to report" was honoured as a measured, reported deviation, and the runtime patch changeset covers the line.
  2. Already self-locked rows: the documented operator delete, judged against the retriage — right. Retriage 6229341285 asked the spec seat to say in the PR whether such rows need a remedy, a startup re-scope or a documented operator delete, and the claim's file surface made a code remedy a stop-and-report. The PR states the remedy and the changeset carries it (delete the package's sys_packages row, then restart). Measured right beyond the dev's reasons: the hydration loop (service-package index.ts, about 693 to 742) replays convertPackageBodyForward on every stored body at each boot and re-installs it 'authored', so a startup re-scope would be an ADR-0087 conversion entry, and the stored body alone cannot tell an authored self-lock from a cloud row that the predicate's own comment says is marketplace / control-plane delivered and read-only by design — a blanket conversion would unlock rows that must stay locked. The dev's "not measured: what happens to sys_metadata items such a package owned" is answered by reading: the same predicate that locked the row (ADR-0070 D1 / D2) refused every authoring write into it, and an AUTHORING-stage manifest's objects are glob patterns, not stored items, so a self-locked row owns no authored items to orphan. "No API door removes it" is true: requireWritablePackage guards delete, and ADR-0070 D2 defines no escape hatch.
  3. Studio NOT MEASURED — measured here (① item 4): default create path unbroken; an explicit platform-scope choice is now refused loudly with the remedy; the create form's option list is an objectui-side follow-up for the adopting seat to file, not a block.
  4. metadata-protocol installPackage parses only the manifest id, so the primitive stores manifests the public install door refuses (type 'application', scope 'environment' from cloud's AI authoring) #24086 note — an acceptance note, correctly not filed. The stated inheritance condition (parse PackageInstallManifestSchema or the body union, not ManifestSchema) is right.
  5. PR body miscount ("3 exports added" against four shard entries) — prose; no action.
  6. Governance: 0 of 14 paths on the register (Governed Surface Queue Guard success); this record is owed by the schema face, not by Tier S.
  7. main moved three commits past the base; none touches a diff path, and CI judged the merge ref green (mergeable_state: clean).

Implemented-by: session_015p3xNTnaiNHYkAHoQ4y5Cg
Reviewed-by: session_01HSDGREvqxQUt7K7pjHXMyC

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/m tests tooling

Projects

None yet

2 participants