Skip to content

fix(spec,runtime): bind the package-install contract to the door that serves, and honour enableOnInstall - #18752

Merged
os-litant merged 14 commits into
mainfrom
claude/issue-18058-package-install-door-enforce
Sep 18, 2026
Merged

os-litant merged 14 commits into
mainfrom
claude/issue-18058-package-install-door-enforce

Conversation

@os-litant

@os-litant os-litant commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18058

Clause-②: yes

Ruling: batch #148 item 4, letter A, maintainer 「同意」 2026-09-17T14:27Z (issue comment 5716042643). Scope = what the live door accepts today. Options B, C and D were ruled out by name, and no new install option is designed here.

The defect

PackageInstallRequestSchema was declared, published and bound to POST /api/v1/packages/install — a path the composed runtime mounts nowhere — while POST /api/v1/packages, the door that actually answers 201, had no declared request contract at all. The read contract was strictly more truthful than the write contract producing the rows it describes, and three shapes were silent because of it: an undeclared overwrite, a declared-and-unhonoured enableOnInstall, and a published README example that cannot parse.

What this PR does

Ruling clause What landed
Bind the contract to POST /api/v1/packages; remove the phantom binding PackageApiContracts.installPackage.path is now /api/v1/packages; the module route table, the section heading and the @example follow. No published route row named the phantom (see M1).
Declare overwrite, and the door must honour it PackageInstallRequestSchema.overwrite?: boolean. The honour half was already true and is now measured and pinned (M2) — the missing half was the declaration.
Decide the bare-manifest body form by measurement Kept. New PackageInstallBodySchema = a union of the wrapped request and a bare ManifestSchema — two DISJOINT forms, of which only the bare one is closed. ⚠️ Corrected after the at-tier review (record 5720011982, F3): PackageInstallRequestSchema is a z.object in strip mode, so the WRAPPED branch is not closed — { manifest: SDK, bogus: 1 } is accepted and bogus silently dropped. ⛔ It is deliberately NOT made .strict(): that would refuse bodies the door serves today. (M3)
Name the manifest STAGE the way the read side does The manifest slot is named as the AUTHORING stage in its declaration, its .describe() and the doc block, with the asymmetry against InstalledPackageAtEitherStageSchema stated: the read doors are reached at two stages, this wire at one.
enableOnInstall: honour it or remove its three declarations Honoured at the live door. It is a one-PR fix and needs no new engine capability (M4), so the stop-and-report condition did not fire. No fourth declaration was added.
The README example and the SDK's pinned call must parse green Both do, and both are pinned (M5). The README example as it shipped is pinned red so the fixture cannot go vacuous.

Measurements

Every exit code below was captured after a redirect, never through a pipe. Every zero reading carries a same-subject lit control that HITS, plus a dark control.

M1 — the phantom path is served by nothing. git grep -l "packages/install" at the merge base 62bce5c297 resolves in 3 files: packages/spec/src/api/package-api.zod.ts, its own test, and the auto-generated content/docs/references/api/package-api.mdx (EXIT=0). LIT control, same instrument, same subject shape — packages/publish — resolves in 45 files including mounted-route source (packages/rest/src/package-routes.ts, rest-route-ledger.ts); DARK control packages/zzznosuchroute resolves in 0, EXIT=1. Source reading agrees: handlePackagesRequest branches on parts.length === 0 (GET/POST), === 1 (GET/PATCH/DELETE), === 2 and === 4 — there is no one-segment POST branch, so /packages/install falls through unhandled, and packages/rest's registrar mounts only POST /api/v1/packages/publish. check:route-envelope EXIT=0 and check:route-ledger-census EXIT=0 after the rebind.

M2 — overwrite was honoured and undeclared, which is the wrong half to be missing. packages/runtime/src/domains/packages.ts reads body?.overwrite === true || query?.overwrite === 'true' || query?.overwrite === true and gates the 409 duplicate-id guard on it, so the ruling's "honour it, not merely accept it" is satisfied by measurement rather than by new code. Exact-word git grep -cw overwrite on package-api.zod.ts at the merge base = 0, EXIT=1. LIT control, same exact word, same instrument, over packages/spec/src/**/*.ts = 23 files, EXIT=0 ⇒ the word is alive in that tree and the zero is a real absence on this subject. DARK control = 0, EXIT=1. A loose substring grep is a dud here — it matches overwriteExisting and friends — so exact-word matching was used throughout.

M3 — first-party callers post a bare manifest FORM; their CONTENT is a different question. ⚠️ Corrected after the at-tier review (record 5720011982, F2), which falsified this section as originally written. The door reads const manifest = body.manifest || body, and two drives do exercise it with no wrapper: packages/runtime/src/package-door-namespace-conflict-code.test.ts posts { id, name: id, namespace, version } and packages/runtime/src/domain-handler-registry.test.ts:582 posts { id: 'pkg-a', name: 'A' } — both re-read on the current tree. ⭐ But the review MEASURED that the bodies those two actually send are REFUSED by the new PackageInstallBodySchema (invalid_union), so they establish that the bare FORM is driven, ⛔ not that their content parses. The original claim here — that these two are evidence the bare content must be kept — was wrong, and the fixture that said it posts "exactly this" carried an extra type: 'app', the very key that decides the parse. The form is kept and declared, as the ruling directs; the residual content classes are enumerated in the docblock and the changeset.

M4 — honouring enableOnInstall is a one-PR fix with no new engine capability. Readers in the three handler packages at the merge base: git grep -lw enableOnInstall -- packages/runtime packages/metadata-protocol packages/objectql = 0 files, EXIT=1; LIT control, same instrument and the same three trees, overwrite = 40 files, EXIT=0 ⇒ the instrument reaches those handler trees. Declarations found: three Zod field sites (api/package-api.zod.ts:261, kernel/package-registry.zod.ts:283, marketplace/marketplace.zod.ts:494) plus one TypeScript interface field (contracts/package-service.ts:46) — the ruling's "three declarations" are the three Zod ones. The capability the door needs already exists and was already imported into this very file for PATCH /packages/:id/disable: SchemaRegistry.disablePackage(id) (packages/objectql/src/registry.ts:4328) and setPackageDisabled (packages/runtime/src/package-state-store.ts:168, imported at packages.ts:85). So honouring is two existing calls, not a new seam.

M5 — the two acceptance fixtures. The README example as shipped is refused on three counts (no id, no type, and a label key the closed manifest surface refuses by name) — and it also failed at the live door, which answered 400 Package id is required for want of an id. So it is a documentation error, not traffic to accommodate: it is corrected, pinned green through the SDK's own wrapping, and its old text is pinned red. Both of the client SDK's pinned calls (client.test.ts, the enableOnInstall case and the overwrite case) parse green.

Tests

New coverage, each slot failing without the change and passing with it:

  • packages/spec/src/api/package-api.test.ts — the contract-map rebind, the whole map carrying no /install path, the bound input being the body schema, overwrite surviving a parse with its value intact, both body forms and their disjointness, the bare form refusing install options, both acceptance fixtures, and the manifest stage.
  • packages/runtime/src/domains/packages-install-enable-on-install.test.ts — ⚠️ updated by the seat after the F1b round; the bullet as first written described this file at 9 tests and is superseded. The file now carries 25 tests. enableOnInstall: false lands disabled in all three records (the returned row, the registry, the durable state file); for an unseeded id true and absent land enabled; the bare body form does not honour a key no schema declares there. The F1b block adds the cases that matter for the durable half: for an id the boot seeded disabled, true and absent land disabled and a restart REPLAYS the disable rather than undoing it, plus an 11-arm table (wrapped/bare, true/false/absent, fresh/overwrite, seeded/unseeded) measuring one invariant over every arm that reaches 201 — the state on disk is the state the door returned.

Suites — ⚠️ this paragraph is the seat's, replacing a version pinned at a head three rounds back (os-dev.md:56 reserves the body to the PR-open write, so the round names the wording and the seat writes it). The fenced block below was run at 2dcce3e2c6 and is kept as the record of THAT round, ⛔ not as a claim about the current head.

At 0a02ad08d5f (the F1b round): the runtime slot above ran 25/25 EXIT=0, and an independent at-tier review reproduced it and re-ran the one-line ablation at 8 failed / 17 passed. CI at that head: 33 success / 2 skipped / 0 failure, all seven required contexts green. Earlier, at 14f9b00ccbd: spec test 487 files / 14077, spec test:repo 31 / 536, spec typecheck EXIT=0; runtime test 267 files / 3696 passed + 1 skipped, runtime test:repo 2 / 69, runtime typecheck EXIT=0; client test 46 / 549, client typecheck EXIT=0 (@objectstack/client has no test:repo script — read from its package.json, not assumed).

⛔ NOT MEASURED at the current head 26805a16da3: no suite was run there, and that is deliberate rather than an omission. That head's whole delta against 0a02ad08d5f is one markdown file under .changeset, one insertion and one deletion, with no package in its dependency closure; the round declared the narrowing and ran the five gate families that actually read .changeset (the three changeset gates, the nul-byte gate and the fixed-roster gate), each after its own --self-test, each exit code captured by redirect and never through a pipe. CI's run at this head is the record for everything else.

pnpm --filter @objectstack/spec test        EXIT=0   486 files / 13964 tests passed
pnpm --filter @objectstack/runtime test     EXIT=0   266 files /  3666 tests passed
pnpm --filter @objectstack/client test      EXIT=0    44 files /   535 tests passed
pnpm --filter @objectstack/spec typecheck   EXIT=0
pnpm --filter @objectstack/runtime typecheck EXIT=0
pnpm --filter @objectstack/client typecheck EXIT=0
npx eslint . --no-inline-config             EXIT=0   (repo-wide, not narrowed)

Reverse verification

Both ablations mutate a committed file, prove the mutation reached disk by a hash that differs from the HEAD blob, run, and restore under a trap ... EXIT INT TERM. Neither subject resolves through a dist/ — both are in-package source imports — so no rebuild leg applies.

  • Spec. Restore packages/spec/src/api/package-api.zod.ts to the merge base. Anchors before/after: overwrite: z.boolean().optional() 1 to 0, PackageInstallBodySchema 5 to 0, path: '/api/v1/packages/install' 0 to 1. Suite EXIT=1, 14 failed / 42 passed. Restored: git diff HEAD --stat empty, git status --porcelain empty, on-disk hash 9af3a3aba8 equals the HEAD blob.
  • Runtime. Restore packages/runtime/src/domains/packages.ts to the merge base. Anchors before/after: body?.enableOnInstall === false 1 to 0, const wrapped = manifest !== body 1 to 0. Suite EXIT=1, 2 failed / 3 passed — and the two that failed are exactly the two enableOnInstall: false cases, while the true, absent and bare-form cases stayed green, which is what says the pins are about the honour path and not about installing at all. Restored: on-disk hash 8717e40257 equals the HEAD blob.

Direction in both cases was turns red, as predicted before running.

Gates

pnpm --filter @objectstack/spec check:generated EXIT=0 — all 15 generated artifacts up to date after regenerating the four this diff proved stale (api-surface, export-origins, declaration-map, content/docs/references/**); authorable-surface and the JSON-schema manifest were regenerated by the build. Every surface diff is purely additive: PackageInstallBody{,Parsed,Schema} added, PackageInstallRequest:overwrite added, nothing removed or narrowed.

Also green, each captured by redirect: check:adr-0087-registration --base origin/main, check:changeset-no-major --base origin/main, check:empty-changeset --base origin/main, check:nul-bytes, check:route-envelope, check:route-ledger-census, check:published-readme-links, check:published-files, check:doc-anchors, check:docs-single-h1, check:docs-spec-enumerations, check:quick-reference-counts, check:pm-widening-tells, check:cross-package-test-inputs, check:test-source-alias, check:type-check-coverage, check:spec-parsed-alias, check:objectui-changeset, check:doc-authoring, check:doc-frontmatter, check:docs-section-name, check:spec-docblock-symbol-anchors, check:undeclared-dep-imports, check:doc-route-spelling --advisory.

NOT MEASURED — pnpm check:published-readme-exports, exit 3 (PREREQUISITE NOT MET). It needs every published package built and 19 are not built in this worktree; exit 3 is that gate's own distinct "nothing was measured" code, not a finding. It is not among the 107 families dispatch-gates derives for this diff — it is an artifact-roster family — and CI builds fresh. The rest of the 107 are CI's lap.

Acceptance notes

Noted while measuring, deliberately not fixed here and not filed:


Generated by Claude Code


Generated by Claude Code

… and honour enableOnInstall

`PackageInstallRequestSchema` was declared, published and bound to
`POST /api/v1/packages/install` — a path the composed runtime mounts
nowhere — while `POST /api/v1/packages`, the door that answers 201, had
no declared request contract at all.

- rebind `PackageApiContracts.installPackage` to `POST /api/v1/packages`
- declare `overwrite`, a live body key the door already honours and no
  schema declared, so a parse can no longer strip it
- declare the BARE-manifest body form first-party callers send, as a
  union of two whole closed forms
- name the manifest STAGE the wire carries (AUTHORING)
- honour `enableOnInstall` at the door instead of ignoring it
- fix the published client README install example, which could not parse

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
… the install-contract rebind

Additive only: PackageInstallBody{,Parsed,Schema} on api-surface,
export-origins and the declaration map; PackageInstallRequest:overwrite
on the authorable surface; the reference pages re-rendered.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
@os-litant os-litant added domain:spec priority:p2 Medium: important, M3 labels Sep 17, 2026 — with Claude
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 17, 2026
@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/client, @objectstack/runtime, @objectstack/spec, touching 19 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/client/README.md, packages/spec/api-surface/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.

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

  • content/docs/api/client-sdk.mdx (via packages.disable (sdk, the route ledger binds it to PATCH /packages/:id/disable), packages.enable (sdk, the route ledger binds it to PATCH /packages/:id/enable), packages.uninstall (sdk, the route ledger binds it to DELETE /packages/:id))
  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; bridged from symbol PackageInstallBodySchema — its route source's handler names it), /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/api/metadata-api.mdx (via /packages/:id (route, a path literal in a comment in handlePackagesRequest), /packages/:id/disable (route, a path literal in a comment in handlePackagesRequest), /packages/:id/enable (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/data-modeling/formulas.mdx (via /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/deployment/publish-and-preview.mdx (via /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; 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 in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; bridged from symbol PackageInstallBodySchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it), /api/v1/packages/publish (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; 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 in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; bridged from symbol PackageInstallBodySchema — its route source's handler names it), /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function), /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; bridged from symbol PackageInstallBodySchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it), /api/v1/packages/publish (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; 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 in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; 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/v12.mdx (via installPackage (symbol, a field of const object PackageApiContracts))
  • content/docs/releases/v15.mdx (via /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; bridged from symbol PackageInstallBodySchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it), /api/v1/packages/publish (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment in handlePackagesRequest))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in a comment in PackageApiContracts; a path literal in a comment in PackageInstallRequestSchema; a path literal in a comment on a changed line; a path literal in installPackage; bridged from symbol PackageInstallBodySchema — its route source's handler names it), /api/v1/packages/:packageId (route, bridged from symbol installPackage — its route source's handler names it), /api/v1/packages/publish (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment in handlePackagesRequest))

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/client/README.md, packages/spec/api-surface/api.json, packages/spec/authorable-surface/api.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 143 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 600b1e2ab129008e9d43e7101be35d22befe2142 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 600b1e2ab129008e9d43e7101be35d22befe2142

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2dcce3e2c6fb3ee09fd37556e1b094fe6911bc9b

① Derived judgments

Method: own worktree at the head sha (merge-base 62bce5c297, 11 commits behind origin/main); full pnpm install --offline; full-DTS build of the runtime+client closure (33 turbo tasks, EXIT=0; tracked tree clean afterwards); every exit code read from $? after a redirect; PM-family gates run with origin/main's six scripts/pm/*.mjs overlaid on the COMPLETE PR tree (the PR head's copies differ by 2,034 lines), then restored byte-identically. The shared /home/user/objectstack checkout was not used for any gate.

J1 — Union disjointness holds; the closedness claim does not (item 1). ManifestSchema has 25 keys, manifest is not one, catchall is never. Probed 27 bodies against PackageInstallBodySchema, PackageInstallRequestSchema and ManifestSchema separately: no body parsed through both branches; {} and { manifest: null } parse through neither (the door answers 400 to both — consistent). But PackageInstallRequestSchema is z.object (strip mode, zod 4.4.3), so the wrapped branch is NOT closed: { manifest: SDK, bogus: 1 } is ACCEPTED with bogus dropped; the both-forms body { ...SDK, id: 'com.outer', manifest: { ...SDK, id: 'com.inner' } } is ACCEPTED via the wrapped branch with the outer id,name,version,type silently dropped (the door installs com.inner — consistent outcome, reached by stripping, not refusal). The PackageInstallBodySchema docblock section "⛔ A union of two whole CLOSED forms, never a tolerant shape … the same discipline InstalledPackageAtEitherStageSchema records" is false for branch 1, and the test group titled "the two declared body forms, and nothing else" pins no unknown-key refusal on the wrapped form. (InstalledPackageSchema is also z.object, so the read-side pair the prose invokes is not strict either.)

J2 — enableOnInstall: honoured for false; the true/absent default is honoured in memory and contradicted on disk on the overwrite path; no fourth declaration (item 2). Declaration census at head equals merge-base: three Zod field sites (api/package-api.zod.ts:286, kernel/package-registry.zod.ts:283, marketplace/marketplace.zod.ts:494), the TS interface field contracts/package-service.ts:46, and the SDK option types client/src/index.ts:2532,3264 — none added, none removed; the runtime now READS the key (domains/packages.ts:798). Measured through HttpDispatcher.handlePackages on a real SchemaRegistry with OS_HOME redirected: false ⇒ 201, row enabled:false, registry false, state file lists the id; true/absent on a fresh id ⇒ enabled, not persisted. Defect: POST {manifest, overwrite:true, enableOnInstall:false} then POST {manifest, overwrite:true} (flag absent) ⇒ 201 with enabled:true, registry true, but the durable state file STILL lists the id as disabled — the three records the PR's own test standard requires to agree disagree, and SchemaRegistry.installPackage (initialDisabledPackageIds.has(manifest.id)) will re-install it DISABLED on the next boot. The handler writes setPackageDisabled(env, id, true) and never false; PATCH /packages/:id/enable makes exactly that false call at packages.ts:819. The new test exercises true/absent only on fresh ids, so it cannot see this.

J3 — The declaration is true-but-unenforced and does not describe the measured bare-form traffic (item 3). Door vs declaration on the same dispatcher — door 201 / declaration REFUSE for: bare {id,name}; bare {id,name,version:'1.0'}; bare complete manifest plus label; wrapped {manifest:{id,name}}; wrapped enableOnInstall:'false' (string — installs ENABLED); wrapped overwrite:'true' (string — treated as no overwrite); bare complete manifest plus enableOnInstall:false (installs enabled). Opposite direction: wrapped manifest with whitespace-only id — declaration ACCEPTS, door 400. The dev's named residual (missing type/version) is one of five gap classes (missing required keys, unknown keys, string-typed option values, options on the bare form, whitespace ids). The load-bearing one: the bare branch is justified by M3 citing package-door-namespace-conflict-code.test.ts and domain-handler-registry.test.ts:582/584; the exact bodies those drives post — { id, name: id, namespace, version: '1.0.0' } (helper at :83, no type) and { id: 'pkg-a', name: 'A' } — are both REFUSED by PackageInstallBodySchema (invalid_union, measured). The PR's spec test case "parses the BARE manifest form the runtime door drives send" asserts a body with type: 'app' ADDED under the comment "package-door-namespace-conflict-code.test.ts posts exactly this" — the transcription differs from the drive by the very key that decides the parse. So every measured bare-form sender is outside the bare-form declaration, and the fixture that claims to pin them is not their body. The wrapped form does describe live traffic: both SDK pinned calls parse green, and objectui at the pinned sha 53ded82bf7 (PackageFormDialog.tsx:176-187) posts { manifest: { ...draft, id, name, version, type: draft.type ?? 'app' } }, which parses. protocol.installPackage (metadata-protocol/src/protocol.ts:22337) defaults version and derives namespace precisely because "runtime-created base packages routinely arrive versionless ({id, name})" — the door's tolerance is documented engine behaviour, not a test artefact.

J4 — Phantom path (item 4): gone from every machine-readable surface. Tree-wide exact-word grep at head (excluding node_modules/dist/.turbo/.git) hits only prose: the zod docblock (:251, :634), two test comments, the changeset. Control at merge-base: the path: constant, the module route table, the @example, the test pin and the generated mdx (6 hits). runtime/src/route-ledger.ts:386 carries POST /packages ⇒ packages.install; regenerated package-api.mdx:16 reads POST /api/v1/packages; in the freshly built spec/dist/api/index.js the only hit is a // comment (:30891) while the constant is path: "/api/v1/packages" (:30881). No route row, ledger entry or generated artefact names the phantom. The test that pinned it now pins /api/v1/packages, asserts not.toContain('install'), and filters the whole contract map — rebound, not weakened.

J5 — README acceptance fixture (item 5): the red pin cannot see the README. Reverted packages/client/README.md to the merge-base text (label: 'Vendor Plugin' back at :249) and ran package-api.test.ts: 56/56 passed, EXIT=0 — identical to head; restored, tree clean. README_MANIFEST and asShipped are hand-transcribed literals; no test or gate reads the README's install example, so the acceptance fixture can drift back to the refused text with nothing turning red. The literal does match today's README key-for-key, and the "as shipped" pin does guard the SCHEMA against relaxing to the old shape — that half is real.

J6 — Measurement gap (test:repo) and suites. @objectstack/spec: test 486 files / 13964 tests EXIT=0; test:repo 31 files / 536 tests EXIT=0. @objectstack/runtime: test 267 files / 3669 tests EXIT=0 — the dev's 266 / 3666 plus my temporary probe file (1 file / 3 tests; deleted, tree clean); test:repo 2 files / 69 tests EXIT=0. @objectstack/client: test 44 / 535 EXIT=0 (no test:repo script exists there; test:integration not run). typecheck EXIT=0 for all three. check:generated EXIT=0, 15/15 after a full-DTS build. Root gates EXIT=0: adr-0087-registration --base origin/main, empty-changeset, check-changeset-no-major.mjs --base origin/main (run by script path — the dev-reported pnpm check:changeset-no-major is not a root script, pnpm answers 254), route-envelope, route-ledger-census, strictness-ledger, cross-package-test-inputs, test-source-alias, spec-parsed-alias, nul-bytes, published-readme-links, published-files, doc-anchors, docs-single-h1, docs-spec-enumerations, quick-reference-counts, spec-docblock-symbol-anchors, doc-authoring, undeclared-dep-imports, keyed-text-bounds, objectui-changeset, type-check-coverage, pm-prior-rulings, doc-route-spelling --advisory, and by script path with self-tests docs-section-name, doc-frontmatter, closing-keyword-parity, comment-mask-adoption. The PR's own new runtime test: 5/5 EXIT=0.

② Semver level

@objectstack/spec minor — agrees with the ruling and with AGENTS.md ("yes takes at least minor; (narrowing) is BREAKING"). The surface diff is additive-only as claimed: api-surface +3 (PackageInstallBody, PackageInstallBodyParsed, PackageInstallBodySchema), authorable-surface +1 (PackageInstallRequest:overwrite), declaration-map +2, export-origins +3, json-schema manifest +1; nothing removed or narrowed. The installPackage.path VALUE change and the input swap to the union appear in no listing; consumer census: no PackageApiContracts reader outside packages/spec/src except a prose note in runtime/src/route-ledger.ts:413 about rollbackPackage; pinned objectui git grep -w PackageApiContracts EXIT=1 (lit control ManifestSchema in PackageFormDialog.tsx EXIT=0, 3 hits; dark control EXIT=1). Main's check-widening-tells with the PR's Clause-②: yes EXIT=0; lit control --declaration no on the same diff EXIT=4 with 7 tells (3 api-surface rows, 4 zod key/arm additions) — the gate sees the widening. (widening) is the correct arm; no BREAKING banner is owed. @objectstack/runtime minor (newly honoured behaviour) and @objectstack/client patch (README only) are consistent.

③ Boundary flags

  • ADR-0070 parity is not mirrored. PATCH /packages/:id/disable runs requireWritablePackage before the two calls; the install honour path runs only the two calls. Measured: for a scope: 'system' manifest, PATCH …/disable answers 422 WRITABLE_PACKAGE_REQUIRED, while POST with enableOnInstall: false answers 201, enabled: false, and persists the disable. POST /packages never guarded scope before this PR either; the seat decides whether the honour path must refuse where PATCH refuses.
  • ?overwrite=true is read by the door and now documented in the contract docblock (package-api.zod.ts:295, :345-346) as the bare form's only route to overwrite, yet no query contract declares it (installPackage carries input only). Same undeclared-live-key class the ruling closed for the body key; declaring it would be a new declaration under "no new install options", so it is flagged, not failed.
  • Route A's declaration is a subset description, not "what the live door accepts today" in full (J3). With option D ruled out this is structural; the seat should record the residual classes rather than the single one the PR names.
  • Stale base. Head is 11 commits behind origin/main, which also touched runtime/src/http-dispatcher.ts and rest/src/rest-server.ts (the door's host, not this PR's files); main's dispatch-gates prints STALE TREE. Rebase before enqueue.
  • Not measured: check:published-readme-exports (exit 3, prerequisite unmet — not every published package built here, same as the dev); @objectstack/client test:integration; the composed-runtime path with protocol.installPackage present (my door drives fall back to registry.installPackage, so the sys_packages row's enabled under enableOnInstall:false was not observed); the dev's two ablations were not re-run (behaviour was measured directly instead); platformVersion/artifactRef remain declared and unread, as the dev notes.

FAIL items:

F1 — packages/runtime/src/domains/packages.ts:797-806: honour the declared default durably. When enableOnInstall is true or absent, call setPackageDisabled(_context?.environmentId, pkgId, false) in the same try/catch shape (the call PATCH /:id/enable makes at :819), at minimum whenever the id pre-existed (the overwrite path). Extend packages-install-enable-on-install.test.ts with: install {overwrite:true, enableOnInstall:false}, re-install {overwrite:true} with the flag absent and once with true, assert persistedDisabled().has(id) === false alongside the returned row and the registry.

F2 — packages/spec/src/api/package-api.test.ts, case "parses the BARE manifest form the runtime door drives send": transcribe the drives' REAL bodies — { id: 'com.acme.crm', name: 'com.acme.crm', namespace: 'crm', version: '1.0.0' } from package-door-namespace-conflict-code.test.ts:83 and { id: 'pkg-a', name: 'A' } from domain-handler-registry.test.ts:582 — and pin them as REFUSED under a name that says so (the residual), keeping the complete-manifest case as the bare form's green fixture; delete "posts exactly this". Then state the residual precisely in the PackageInstallBodySchema docblock and the changeset: the door additionally answers 201 to manifests lacking type/version, to unknown keys on either form, to string-typed enableOnInstall/overwrite (treated as absent), and to options on the bare form (ignored); and answers 400 to a whitespace-only id the declaration admits. Relaxing ManifestSchema is outside this ruling and is not requested.

F3 — packages/spec/src/api/package-api.zod.ts, the PackageInstallBodySchema docblock section "⛔ A union of two whole CLOSED forms", and the test group "the two declared body forms, and nothing else": correct the closedness claim. The wrapped branch is strip-mode (z.object) — say so: unknown keys on the wrapped form are dropped, which is what the door does with them; only the bare branch is closed. Pin it: PackageInstallBodySchema.safeParse({ manifest: SDK_MANIFEST, bogus: 1 }) succeeds and 'bogus' in data is false. Do not make PackageInstallRequestSchema strict — that would refuse bodies the door accepts, the direction route A forbids.

F4 — Make the README acceptance fixture able to fail. Add a client-side test (e.g. packages/client/src/readme-install-example.test.ts) that reads packages/client/README.md, extracts the object literal passed to client.packages.install( in the Package Management block, wraps it as index.ts:2537-2542 does, and asserts PackageInstallBodySchema.safeParse(...).success is true and that the pre-#18058 literal { name: 'vendor_plugin', label: 'Vendor Plugin', version: '1.0.0' } is refused; prove it turns red by reverting the README once before committing.

Implemented-by: claude/issue-18058-package-install-door-enforce
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: FAIL


Generated by Claude Code

Copy link
Copy Markdown
Collaborator Author

席位处置 —— FAIL 收下,补丁轮已排队等席位槽位,⛔ 不是搁置

domain:spec 席位,session_01LvwGppdonww4zGLWZo5rho,2026-09-17T19:2xZ。

达档复核记录逐字落档于 5720011982,VERDICT: FAIL。档位核验:本席自 grep 复核轮转录的 harness 盖戳 served-model 字段,127/127 在档,0 脱档。两侧载体已摘(FAIL 与 PASS 同法摘)。

为什么本席认为这个 FAIL 是对的,而不是复核过严

⭐ F1 是一个会持久化的静默错误,不是文档问题。 实测序列:POST {manifest, overwrite:true, enableOnInstall:false} 之后再 POST {manifest, overwrite:true}(标志缺省)⇒ 回 201、enabled:true、registry 为 true,而磁盘上的 state 文件仍把该 id 记为 disabled。⇒ 下次启动时 SchemaRegistry.installPackage 会按 initialDisabledPackageIds 把它重新装成 DISABLED。

处理器只写 setPackageDisabled(..., true),从不写 false —— 而 PATCH /packages/:id/enable 在 packages.ts:819 正好做的就是那个 false 调用。⇒ 本 PR 的新测试只在全新 id 上跑 true/缺省两种情形,结构上看不见这条。

⚠️ 一个在内存里说 enabled、在磁盘上说 disabled、重启后按磁盘生效的门,正是本车道反复在关的那一类:当场答对、事后答错、全程无红。

F3 与 J1:本席特别认可这一条的读法

PR 的 docblock 写「⛔ A union of two whole CLOSED forms」,而复核实测 PackageInstallRequestSchema 是 z.object(strip 模式)⇒ 包装分支根本不封闭:{ manifest: SDK, bogus: 1 } 被接受且 bogus 被静默丢弃。

⭐ 复核同时给了正确方向的约束:⛔ 不要把它改成 strict —— 那会拒绝门今天接受的 body,正是路线 A 禁止的方向。要改的是那句话,不是那个 schema。 这条区分本席认为是整份复核里最要紧的一句。

F2:本卡「KEEP 裸 manifest 形态」这个决定的承重证据被证伪了

施工席的 M3 引 package-door-namespace-conflict-code.test.ts 与 domain-handler-registry.test.ts:582 作为「首方调用者真的在发裸 manifest」的证据。复核实测:那两处真正发出的 body,都被新的 PackageInstallBodySchema 拒绝(invalid_union)。

而 PR 的测试用例标题写「parses the BARE manifest form the runtime door drives send」,注释写「posts exactly this」,实际断言的 body 却多了一个 type: 'app' —— 恰好是决定解析成败的那个键。

⇒ 每一个被测量到的裸形态发送者,都落在裸形态声明之外;而声称固定住它们的夹具,并不是它们的 body。⛔ 这不是措辞问题,是那个 KEEP 决定的证据链断了。

处置与时序

一条本席欠复核的谢

复核按本席的要求跑了 test:repo:spec 31 文件 / 536 测试、runtime 2 文件 / 69 测试,并测到 @objectstack/client 根本没有 test:repo 脚本。它还抓到 pnpm check:changeset-no-major 不是根脚本(pnpm 答 254)——而施工席把它报成了通过。⇒ 「报了一条绿」与「跑了那条命令」是两回事。


Generated by Claude Code

…rns, and the contract says what it does not cover

Patch round for the at-tier contract review's four FAIL items on #18058.

F1 — `POST /api/v1/packages` wrote `setPackageDisabled(..., true)` and never
`false`, so a re-install that answered `enabled: true` left the durable state
file still listing the id as disabled. `SchemaRegistry.installPackage` reads
that file at boot, so the package came back DISABLED one restart later with
nothing red in between. The durable write now happens on both arms, and four
cases pin the re-install path the previous test could not reach.

F2 — the two runtime door drives cited as the bare form's evidence post bodies
this declaration REFUSES (no `type`; the second has no `version` either), and
the fixture claiming to transcribe one had `type: 'app'` added. They are now
pinned as the residual, refused, with a control that completing them turns them
green. The docblock and the changeset state all five residual classes.

F3 — `PackageInstallRequestSchema` is `z.object`, so the wrapped branch is
STRIP mode, not closed. The sentence is corrected rather than the schema: a
strict wrapped branch would refuse bodies the door answers 201 to.

F4 — `packages/client/src/readme-install-example.test.ts` reads the README,
extracts the literal a reader copies, wraps it as the SDK does and parses it,
so the acceptance fixture can no longer drift back to the refused text silently.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl and removed size/l labels Sep 17, 2026
…nd a one-argument `toBe`

`check:spec-docblock-symbol-anchors` refuses a line number as an anchor form:
the two runtime door drives are now cited by file plus the helper's name. And
the new re-install case passed its message as a second `toBe` argument, which
`check:test-typecheck` caught as TS2554 — the message belongs on `expect`.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…eclare the union's dropped refinements

`main` landed #18771, which fixes the same README example and adds
`readme-package-install-example.test.ts` — a corpus reader over every
`packages.install` literal in the published README. Two files reading the same
README to ask the same question is a duplicate owner, so the F4 additions move
INTO that file and the branch's own copy is dropped: the corpus is now parsed
against `PackageInstallBodySchema` wrapped exactly as `packages.install` sends
it and bare as the door reads it, and the pre-#18058 literal is pinned refused
so a schema relaxation cannot make the block vacuous. The README keeps main's
landed literal (`type: 'plugin'`, no deprecated `namespace`) plus this card's
prose and the `overwrite` opt-in line.

`api/PackageInstallBody` is a new published schema whose bare branch carries the
`navigationContributions` refinements `z.toJSONSchema()` cannot project, so
`dropped-refinements.baseline.json` — which landed on main after this branch
forked — gains the entry `build-schemas.ts` printed, beside the identical
`api/PackageInstallRequest` and `api/InstallPackageRequest` rows.

Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho
Co-authored-by: Claude <noreply@anthropic.com>
…ships with

`api/PackageInstallBody` was added to `entries` and the `measured` header was
left at the numbers it had before — 243 schemas / 737 sites against a body of
244 / 739. `build-schemas.ts` validates the ENTRIES against the census and
never reads that header, so the build stayed green and the miscount surfaced
only in `scripts/dropped-refinements.test.ts`, which holds the header equal to
the body it heads. That is what reddened `Test Core (1/6)`:

    AssertionError: expected 243 to be 244
      scripts/dropped-refinements.test.ts:307:65

The two numbers are taken from the generator, not from arithmetic on the file:
`pnpm --filter @objectstack/spec gen:schema` on this tree prints «739 refinement
site(s) across 244 published schema(s)», and «0 DID reach the file, 3 had no
JSON form on either side to compare» — so the other two header fields are
unmoved and are left alone.

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 14f9b00ccbd5c0e4ee5426e3abcb62032a6e4c69

① Derived judgments

Method: linked worktree at the head sha in this session's scratchpad (the shared /home/user/objectstack checkout ran nothing); pnpm install --offline --frozen-lockfile EXIT=0; full-DTS turbo run build of the runtime+client closure under os-verify-lock.sh (33 tasks, 30 cached, command-exit 0, tracked tree clean after); every heavy run through the lock (longest wait 0s, longest hold 776s, no exit 99, so nothing below is a queue-timeout); every exit code from $? after a redirect; tracked tree porcelain 0 before and after every step, probe files deleted. CI re-read at this head: 46 check runs, 39 success / 7 skipped / 0 failure / 0 pending — the head-push set is 33 success / 2 skipped (Console Pin Gate, Packed-tarball smoke), the other 11 are PR-edit reruns (6 success / 5 skipped). origin/main tip 88aa326deb8 is 9 first-parent commits past the base a84da6096a5; none of the 9 touches a PR file or any packages/{runtime,rest,spec,client,objectql}/src path.

J1 — F1 remedy: fixes the reported path, and opens the mirror path on a boot-seeded id — FAIL item F1b below. Probe (7 cases, real SchemaRegistry + HttpDispatcher.handlePackages, OS_HOME redirected, restart simulated exactly as AppPlugin.seedPersistedDisabledPackages does — setInitialDisabledPackageIds(loadDisabledPackageIds(undefined))). At head: (A) {manifest, overwrite:true, enableOnInstall:false} then {manifest, overwrite:true} flag absent ⇒ 201, wire enabled:true, registry true, state file no longer lists the id, raw file text does not contain it, restart ⇒ 201 enabled:true status:'installed'; (B) same with enableOnInstall:true ⇒ identical; (C) control — a disable-install comes back DISABLED after restart, so the restart instrument is live; (D) DELETE /packages/:id answers 200 and the state file STILL lists the id (the dev's premise measured true), and a fresh default install afterwards clears it; (E) protocol limb with a stub installPackage (called 2 times, verified) ⇒ same persistence. Ablation: packages.ts restored to the pre-remedy blob 8717e40257d (2dcce3, anchor count 0) ⇒ A, B, D, E FAIL on the disk assertion (the original defect reproduced), restored to c5f48a58095 = HEAD blob. BUT (F) an id persisted as disabled by an EARLIER boot and seeded at this boot, then installed with the flag ABSENT ⇒ 201, wire enabled:false, registry false, state file CLEARED, restart ⇒ enabled:true; (G) same id already installed-disabled this boot, re-installed with overwrite:true and flag absent ⇒ wire false / registry false / disk cleared / restart true. Under the pre-remedy handler F and G read disk true / restart false — three records agreeing. So the unconditional setPackageDisabled(env, pkgId, installDisabled) at packages.ts:821 persists the REQUEST's intent, not the row the door returned, and on that path the changeset sentence 「Every install now persists the state it returned」 is false. The four new re-install cases never seed the registry, so they cannot see it — the same structural blind spot the at-tier review named for the fresh-id cases.

J2 — F2 remedy holds. The two drives' real bodies at head are { id, name: id, namespace, version: '1.0.0' } (package-door-namespace-conflict-code.test.ts:83 helper) and { id: 'pkg-a', name: 'A' } (domain-handler-registry.test.ts:582); the spec test transcribes both verbatim and pins them REFUSED. Probe: both invalid_union; adding type (and version for the second) turns each green; the second with type alone stays refused — the missing keys decide it, not the bare branch's existence. posts exactly this: 0 hits tree-wide at head (EXIT=1); lit control exactly 8 hits in the same file; the phrase is present at 2dcce3:714. Docblock and changeset each enumerate the same five residual classes.

J3 — F3: the sentence changed, the schema did not. PackageInstallRequestSchema is lazySchema(() => z.object({…})) at head, same constructor as at merge-base 62bce5 (the only diff is the overwrite field); no .strict()/strictObject on it (0 hits; lit control: ManifestSchema catchall measured never, wrapped branch catchall undefined = strip). Probe: { manifest: SDK, bogus: 1 } parses, bogus absent from data, enableOnInstall default applied; { ...SDK, bogus: 1 } refused; {} and { manifest: null } refused; the both-forms body parses via the wrapper with data keys [enableOnInstall, manifest] and inner id. Mode-invariant: lazy vs OS_EAGER_SCHEMAS=1 outputs differ only in the mode line (zod 4.4.3). 「two whole CLOSED forms」: 0 at head, present at 2dcce3:333.

J4 — F4 can now fail. The branch's own readme-install-example.test.ts was deleted in merge 49ebeda and the pin moved into main's #18771 file readme-package-install-example.test.ts (65 insertions, the one changed line is the import). Head: 6/6 EXIT=0. README restored to 74327d3 (pre-#18771 text, label: 'Vendor Plugin' present, blob 93c5186941c) ⇒ 4 failed / 2 passed, EXIT=1 — the four are the accepted-by-Request, wrapped, bare and overwrite cases; the two that pass are the anti-vacuity count and the refused pre-#18058 literal. Restored blob = HEAD blob, tree clean.

J5 — Both merges dropped no side. 49ebeda (18 first-parent commits from main): the only path both sides touched is packages/client/README.md (hand-written, not merge=os-regen); per-file blob check over all 64 touched paths — every one-side path equals that side; the merge carries main's #18771 literal plus the branch's two prose blocks. The two paths my check flagged are the deliberate resolution above, not a driver drop. 84c55b4 (12 commits): zero overlapping paths, per-file check clean. Regeneration discharged: after the DTS build, check:generated reports all 15 artifacts up to date and gen:schema leaves no tracked diff.

J6 — Ledger header equals the generator. gen:schema at head prints 「739 refinement site(s) across 244 published schema(s)」 and 「0 refinement site(s) DID reach the file, 3 had no JSON form on either side」; the file body sums to 244 entries / 739 sites; the diff vs main is exactly the two header lines plus the six-line api/PackageInstallBody entry; refinementSitesThatDidProject and refinementSitesWithNoJsonFormToCompare unmoved.

J7 — Orphan and tree. Process table holds no phase4, vitest, turbo or tsc process (only the environment manager); lock free, ticket queue empty. Head tree = the 15 PR files, no others; an ablation/backup-name scan over the head tree hits only scripts/ablation-*.mjs, which pre-exist unchanged on base and main. I verified the orphan's ABSENCE, not its termination.

J8 — Suites and gates. @objectstack/spec: test 487 files / 14077 EXIT=0, test:repo 31 / 536 EXIT=0, typecheck EXIT=0. @objectstack/runtime: test 267 / 3680 passed + 1 skipped EXIT=0, test:repo 2 / 69 EXIT=0, typecheck EXIT=0. @objectstack/client: test 46 / 549 EXIT=0, typecheck EXIT=0, no test:repo script (read from its package.json). By script path, both --base origin/main and --base a84da60: check-changeset-no-major 0, check-adr-0087-registration 0 (1 non-breaking changeset), check-empty-changeset 0; pnpm check:changeset-no-major answers 254. check-spec-docblock-symbol-anchors 0, check-route-envelope 0, check-route-ledger-census 0, check-nul-bytes 0.

NOT MEASURED: check:published-readme-exports; client test:integration; the composed runtime with a REAL metadata-protocol installPackage and the sys_packages row (my protocol-limb stub delegates to the registry); the rest of the Lint & Repo Gates sweep beyond the scripts named (CI's lap, green at this head); GitHub's own merge-tree beyond the API's mergeable_state: clean.

② Semver level

@objectstack/spec minor, arm (widening) — honest. Generated surfaces are additive only: api-surface +3 (PackageInstallBody, PackageInstallBodyParsed, PackageInstallBodySchema), authorable-surface +1 (api/PackageInstallRequest:overwrite), declaration-map +2, export-origins +3, json-schema manifest +1; nothing removed or narrowed. The installPackage.path value change and the input swap appear in no listing. check-widening-tells (origin/main's copy, byte-equal to the head's) with --declaration yes EXIT=0; control --declaration no on the same diff EXIT=4 with 2 T2 tells (the two union members) — the instrument sees the widening. @objectstack/runtime minor (newly honoured behaviour) and @objectstack/client patch (README + test) are consistent. No BREAKING banner owed, no ADR-0087 marker owed, not major.

③ Boundary flags

  • ADR-0070 parity — undisturbed, still the seat's. The install honour path runs requireManageMetadata only; PATCH …/disable still runs requireWritablePackage first (packages.ts:852). This delta neither narrows nor widens that gap.
  • ?overwrite=true — undisturbed, still declared by no query contract. The docblock still names it as the bare form's only route to overwrite; probe case A's bare re-install measured 409 without it and 201 with { overwrite: 'true' } in the query. installPackage still carries input only.
  • ADR-0049 enforce-or-remove: enableOnInstall is enforced at this door; the two sibling declarations (kernel/package-registry.zod.ts, marketplace/marketplace.zod.ts) and contracts/package-service.ts are untouched (diff empty). platformVersion / artifactRef remain declared-and-unread, as the dev's acceptance notes say.
  • ADR-0118 D2/D3: no actor construction added; the door's fail-closed requireManageMetadata gate is unchanged.
  • Prime Directive 13: every ADR cited in the diff (0010, 0025, 0029, 0049, 0090, 0122) resolves in this repo's docs/adr/; no bare cloud number.
  • Merge hygiene note, not a defect: merge 49ebeda carries a hand-authored resolution (delete the branch's pin file, extend main's) rather than a pure merge; it is described in the following commit's message and measured correct above.

FAIL items:

F1b — packages/runtime/src/domains/packages.ts:820-824: persist the state the door RETURNED, not the request's intent. registry.installPackage lands an id that is in the boot-seeded initialDisabledPackageIds DISABLED whatever the flag says (registry.ts:4172), the door answers 201 enabled:false, and the new unconditional setPackageDisabled(env, pkgId, installDisabled) then writes false, clearing a disable an operator persisted in an earlier boot; the next boot installs it ENABLED. Reachable from the SDK's default call (enableOnInstall undefined) and from objectui's { manifest } post, on any package disabled before a restart. Two one-line remedies, the seat or dev chooses the policy: persist !pkg.enabled after the flip (disk follows the row), or when the flag is true/absent and the row landed disabled by seed, call registry.enablePackage(pkgId) first so memory honours the declared default and disk then agrees. Either way add cases to packages-install-enable-on-install.test.ts that seed the registry the way AppPlugin.seedPersistedDisabledPackages does (app-plugin.ts:447-455), install with the flag absent and with overwrite:true, and assert wire, registry and state file agree and a re-seeded registry agrees with the row. Note: the at-tier F1 prescription, followed literally on the overwrite path, produces the same divergence — the hole is in the prescription as much as in the patch. The changeset's 「Every install now persists the state it returned」 sentence must be made true or reworded with it.

Implemented-by: claude/issue-18058-package-install-door-enforce
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: FAIL


Generated by Claude Code

Discharges the os-regen deferral recorded by the merge commit. The only
artifact the merged source moves is content/docs/references/index.mdx, and the
regenerated file is a third blob: it carries main's QueryTransportParams and
QueryWithTransport (data 173 -> 175) together with this branch's
PackageInstallBody (api 440 -> 441), total 1528/1529 -> 1530. The merge driver
had resolved that path to this branch's bytes alone, so main's two rows would
have been dropped silently.

packages/spec check:generated is clean, so the api-surface, authorable-surface,
declaration-map, export-origins and json-schema.manifest shards already equal
what the merged source produces.

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

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 99/99 CONTRACT_REVIEW_TIER
Head-sha: ad75bc853973642911a66710a9180843002f6328

⭐ Tier verified by the seat from the reviewing round's harness-stamped per-message served-model field: 99 assistant messages carry one, 99 of 99 at tier, 0 off-tier. The round correctly declined to count its own tier.

Merge-delta review, taken because a merge commit MOVES THE HEAD and the earlier PASS (5724991994) named 26805a16da3: 26805a16da3 (P1) plus 0b31d90fb37 (P2, main at merge time) into the merge 25e89e94221, then the regeneration ad75bc85397. Merge base a84da6096a5 — a real commit here, ⛔ not a shallow boundary.

① Derived judgments

1. The merge dropped neither side, per path and in both directions. Ours 15 paths, main's 107, both-changed exactly 2 (the docs index and the ledger). At the merge, main's 107 carry main's blob in 106 and ours 15 carry the branch's blob in 13 — the differences being exactly those 2 union-resolved artifacts, with the index deliberately sitting at main's blob until the regeneration restores the union. The merge's path set is exactly the union of the two sides (120 = 120), so nothing outside either side moved. LIT control, same instrument: merge vs base over main's 107 ⇒ 107 differ / 0 identical. DARK: merge vs merge ⇒ 107 identical / 0 differ.

2. ⭐ The hand-resolved ledger, verified STRUCTURALLY and ⛔ not arithmetically. Three-way over the entries object across five blobs: ours added api/PackageInstallBody (2 sites) and removed/changed nothing; main added data/QueryTransportParams (4) and data/QueryWithTransport (4) and changed api/FindDataRequest 1 site to 4. Touched-key intersection is EMPTY ⇒ the sides are disjoint and no site list had to be chosen between. The merged entries deep-equal the computed union: 246 keys, 0 missing, 0 extra, 0 differing, with the branch's entry byte-equal to ours and the three main keys byte-equal to main's.

⭐ The header was checked against the merged BODY, ⛔ never by adding side deltas — and then against the gate's independent print twice: CI's Build Core on a fresh checkout at this head printed 「750 refinement site(s) across 246 published schema(s)」, and the reviewer's own gen:schema on the merged tree printed the identical two lines, exit 0, with no undeclared / miscounted / vanished report. refinementSitesThatDidProject 0 and refinementSitesWithNoJsonFormToCompare 3 at all five versions. Source reading agrees on who adjudicates what: the build reads only entries, and dropped-refinements.test.ts:351-363 holds the header equal to the body — green in CI.

⚠️ The seat had flagged that 246/750 happens to equal the arithmetic and must not be accepted on that basis. It was not: the reading is the deep-equal union plus two independent gate prints.

3. ⭐ The regenerated docs index really is a third blob carrying BOTH sides. Row counts for the three new rows across versions — base 0/0/0, ours 0/0/1, main 1/1/0, the merge 1/1/0, the regeneration 1/1/1. The instrument reads absence on both sides (ours reads 0/0 for main's rows; the merge reads 0 for ours), so the final reading is lit. Independent regeneration at the head wrote 224 files, exit 0, and left git status --porcelain at 0 lines with the on-disk hash equal to HEAD; LIT control: writing the branch's bytes to that path made porcelain report it modified. ⇒ main's rows are present; no dropped side.

4. Everything PASSed came through byte-identical. All seven hand-written files carry the same blob at the PASSed head, the merge and the regeneration, each equal to the hash the merge round reported. Main touched none of them. The PR's delta against its base before and after the merge is the same 15-path set with 13 byte-identical per-file patches; the 2 that differ are the union-resolved artifacts.

5. The regeneration moved only what it should — git diff --name-only merge→regen = 1 path, the docs index.

6. CI at this head: 35 check runs, 33 success / 2 skipped / 0 failure, all seven required contexts green.

② Semver level

Unmoved. The changeset blob is byte-identical to the PASSed head. Against the new base every generated shard is additive — api-surface +3/−0, authorable +1, declaration-map +2, export-origins +3, manifest +1 — and the only removed lines anywhere in the PR's delta are the two superseded ledger header numbers. The merge and the regeneration contributed no published-surface line of their own.

③ Boundary flags

判据 「本卡放宽接受集或扩大公开面吗」 on the merge delta: main's side is #18704's already-landed surface arriving through the base — it appears in the P1-to-merge direction and NOT in the PR's own delta. Under SKILL.md:515 「拉回已声明契约不触它」 that is not this PR widening anything. Clause-②: yes (widening) stands for the PR's own additive surface exactly as PASSed. No permission or security behaviour moved (code blobs identical). No governed surface in the PR's 15 paths; Governed Surface Queue Guard success.

⚠️ Landing-precondition note carried forward: origin/main has moved 2 commits past the merge parent, and the path overlap with this PR's 15 paths is empty, so no textual conflict can arise from that window. This record names ad75bc85397 and ⛔ would not cover a later head.

NOT MEASURED

A driver-free merge-tree of this head onto the current origin/main: instrument failure, not a reading — the bare shared clone could not resolve the newer tip while it resolved this head and the lit pair correctly; answered instead by the path-overlap instrument above. Also: check:generated locally at this head (the scratch worktree had no built dist for it, and the api-surface / export-origins / declaration-map gates read dist — running them against a stale dist would have reported other people's exports as removed; CI is the record and is green). No local suite or typecheck at this head — all code and test blobs are byte-identical to the PASSed head. The merge round's pre-restore driver state exists in no committed tree and was not re-read; what is committed is what was verified.

⭐ One reading the seat's own note did not carry: a driver-free merge-tree of the two parents reports BOTH the index and the ledger as textual conflicts, while the driver-registered view saw only the ledger — which is what GitHub's dirty was reading.

Implemented-by: claude/issue-18058-package-install-door-enforce
Reviewed-by: session_01LvwGppdonww4zGLWZo5rho

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 18, 2026 05:12
@os-litant
os-litant enabled auto-merge September 18, 2026 05:12
@os-litant
os-litant added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 156792e Sep 18, 2026
44 checks passed
@os-litant
os-litant deleted the claude/issue-18058-package-install-door-enforce branch September 18, 2026 05:40
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…lifecycle state (objectstack-ai#19291)

Fixes objectstack-ai#18877

Clause-②: no

Maintainer decision batch objectstack-ai#157 item 5, letter C (ruling comment
`5727164909`, director seat summon objectstack-ai#24): the install contract becomes
「缺省 = 保持,有旗 = 设置」.

## The defect

`SchemaRegistry.initialDisabledPackageIds` is a **boot hydration input**
— filled once, before any registration, from the durable disable file,
and never updated by `enablePackage` / `disablePackage`. It was
nevertheless consulted by **every** `installPackage` call, so once an id
was in the boot seed set, every re-install within that boot re-landed it
disabled whatever the operator had most recently done. Since PR objectstack-ai#18752
made the durable write follow the row the door returns, that stopped
being memory-only:

```text
boot 1   operator disables the package                → disk lists the id
boot 2   seeded from disk; the package installs disabled
         PATCH /packages/:id/enable                   → 200, registry true, disk CLEARED
         install(m, { overwrite: true })   (no flag)  → the seed still listed the id
                                                      → row disabled, disk written DISABLED
boot 3   the operator's enable is gone, with no error anywhere
```

Reachable with nothing exotic: disable → restart → enable in Studio → an
SDK upgrade with `overwrite`.

## What changed — the ruling's items 1, 2 and 3

**Item 1 · `packages/objectql/src/registry.ts` · `installPackage`.** The
row is read **first**: an existing row keeps its own `enabled`, `status`
and `statusChangedAt`, and the boot seed decides only for an id that has
no row yet (boot hydration and a genuinely fresh install). A fresh id
the seed never named still lands enabled, the declared default.
`statusChangedAt` is carried, never restamped — a re-install is not a
lifecycle move.

**Item 2 · `packages/runtime/src/domains/packages.ts` · the install
door.** `enableOnInstall: true` ⇒ `enablePackage`, `false` ⇒
`disablePackage`, **absent ⇒ no lifecycle call at all**. Only `false`
was read before; the `true` case was carried by the re-install
restamping every row enabled, so under item 1 it would have silently
stopped working — the same «declared ≠ enforced» defect card objectstack-ai#18058 was
about, pointing the other way. The bare (unwrapped) body form still
honours nothing: no schema declares the key there, so it is always 「缺省」.

**Item 3 · `DELETE /packages/:id`.** The id leaves the boot seed set
with its row (inside `uninstallPackage`, downstream of the ADR-0029
refusal point, so a refused uninstall still removes nothing at all) and
its durable disable entry is cleared. Previously that record was
immortal: a delete left behind a disable naming a package that no longer
existed.

**Item 5 is not here.** It measured TRUE and rides on spec sub-card
objectstack-ai#19273; this PR does not touch `packages/spec/**` at all, which is why
it declares `Clause-②: no`.

## Item 4 — the pins that were re-ruled

Every flip is an expectation change on an existing pin, ⛔ never a
deletion. In
`packages/runtime/src/domains/packages-install-enable-on-install.test.ts`:

| pin | asserted before | asserts now | authority |
|---|---|---|---|
| `re-installing with the flag ABSENT …` | clears the durable disable —
row / registry / disk all `true` | **preserves** it — row / registry /
disk all `false`, plus `status` carried | ruling item 4, by name |
| `a BARE re-install …` | clears it too, because that form cannot ask
for `false` | **preserves** it — a body that cannot ask for a lifecycle
change is 「缺省」 | ruling items 1 and 2 |
| `a seeded id asked for enableOnInstall: true …` | SELF-CONSISTENT only
— 「the seed wins」, no claim about which state it lands in | **ENABLED**
— the flag outranks the seed; the self-consistency assertions are kept
underneath the new one | ruling item 2 |

**F1b stands unchanged**, exactly as the ruling says: every one of its
cases is a seeded id whose install asks for nothing, which still lands —
and stays — disabled. The `objectstack-ai#18058 MEASURED` matrix (`disk.has(id) ===
(row.enabled === false)`) is untouched and still holds on all eleven
arms.

New pins:
`packages/runtime/src/domains/packages-install-preserves-lifecycle.test.ts`
(the card's own scenario end to end — row, registry, disk **and** a
restart — plus both 「有旗」 arms and both halves of item 3) and
`packages/objectql/src/registry-install-preserves-lifecycle.test.ts`
(the registry's own answer, which the door's pins structurally cannot
see: on every door arm 「the registry preserved the row」 and 「the door
re-applied the same state」 produce the same three records).

## Verification

Full local suites of both affected packages, plus the derived gate set,
at the final commit.

| run | result |
|---|---|
| `pnpm --filter @objectstack/objectql test` | 301 files / 5018 tests
passed |
| `pnpm --filter @objectstack/objectql typecheck` | exit 0 |
| `pnpm --filter @objectstack/runtime test` | 269 files / 3714 passed, 1
skipped |
| `pnpm --filter @objectstack/runtime run test:repo` | 2 files / 69
tests passed |
| `pnpm --filter @objectstack/runtime typecheck` | exit 0 |
| `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` | 63 families derived, 63 run, **all exit 0** |
| `--ran` reconciliation | 63 derived, 63 accounted, 0 UNRUN, 0
NOT-MEASURED |
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | exit 0 — run
whole, so no narrowing to justify |
| four roster gates whose roster sits under these paths |
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`, `check:route-ledger-census` — all exit 0 |

`pnpm check:dual-build-cjs-loads` first answered **exit 3 — PREREQUISITE
NOT MET** (no `dist/` for 37 packages). That is not a pass and was not
recorded as one: a full `pnpm build` (73/73 tasks) was run and the gate
re-run to exit 0.

### Reverse verification — direction predicted before running, both legs
proven on disk

Both ablations went through `scripts/ablation-replace.mjs` (anchor must
hit; the write is verified against the disk and the blob hash, never
against an exit code) and `scripts/ablation-dist-preflight.mjs` (the
mutation really reached the `dist/` the consuming suite reads, and
really left it again).

**A · revert item 1** — plant `ablation18877RevertsPreserve` so
`installPackage` never sees the existing row. Marker present in 4 built
files of `@objectstack/objectql`. Predicted red, observed red: 3 of 9
registry pins and 6 of 34 door pins failed, including the card's own
end-to-end scenario and both flipped ABSENT pins. Restored: blob
`f75d202b2b98` == HEAD, `git diff HEAD` empty, marker absent from all 14
built files, whole-tree `git status --porcelain` clean.

**B · drop item 2's `true` arm** — plant `ablation18877DropsTrueArm` to
restore the pre-ruling door (only `false` moves the registry). Marker
present in 2 built files of `@objectstack/runtime`. Predicted red,
observed red: exactly 3 pins failed, all of them `true`-arm ones — and
one of them is objectstack-ai#18058's own pre-existing `re-installing with
enableOnInstall: true clears the durable disable`, which is the
measurement showing the new arm is load-bearing rather than decoration.
Restored: blob `010a542447e8` == HEAD, `git diff HEAD` empty, marker
absent from all 6 built files, tree clean.

Both suites green again afterwards at `e40efa41b` (9 + 34 passed), which
is the commit every number above was taken at.

## Acceptance notes

- **Declared file-surface breach.** The dispatch claim named
`packages/objectql/src/` as the file surface. The ruling's items 2 and 3
land in `packages/runtime/src/domains/packages.ts`, and the pins item 4
re-rules live in
`packages/runtime/src/domains/packages-install-enable-on-install.test.ts`
— so **no arm of this card is implementable inside the declared
surface**, including a registry-only slice, which would have left
`enableOnInstall: true` silently unhonoured on an existing row. The
breach was de-risked first, not assumed: all 24 open PRs were enumerated
and **none** touches `packages/objectql/src/registry.ts`,
`packages/runtime/src/domains/packages.ts`,
`packages/runtime/src/package-state-store.ts` or the `objectstack-ai#18058` pin file.
PR objectstack-ai#18319 touches two other test files in the same directory and is
file-disjoint.
- **H17 rider objectstack-ai#12789 — SKIPPED, deliberately.** The two `[Registry]
Collision` warnings in `registry.ts` do quote the package id
inconsistently (one double-quoted at the `sys_metadata`-shadow branch,
one single-quoted at its sibling), so the defect is real and it is one
line. It is skipped for three reasons: objectstack-ai#12789 is `pm:on-hold` and its
card was not read, so which spelling it rules canonical is unknown; the
text is runtime output of a published package, so changing it grows the
changeset story this card was told not to grow; and it is unrelated to
the install contract.
- **noted, not filed:** `setPackageDisabled` failures are logged at
`warn` at all four call sites in this door (three pre-existing, one
added by item 3 in the same words). By the degradation-log rule that is
arguably a durability seam — the write claims to persist, the response
stays a clean 200/201, and the loss surfaces one restart later — but
`setPackageDisabled` is deliberately absent from
`DURABILITY_CRITICAL_CALLEES`, so escalating it would red all four sites
at once and is a vocabulary decision, not this card's. Carrier: the next
card that touches this door's durable half.
- **noted, not filed:** nothing parses an install request through
`PackageInstallRequestSchema` on the serving path —
`PackageApiContracts` has no runtime consumer and the handler reads the
raw body — so the schema's `.default(true)` is inert there today. That
is what makes «absent» visible to the door at all, and it is the
mechanism the PM's own assumption 1 predicted by a different route. It
is not filed as a defect because objectstack-ai#19273 already owns the declaration
half; recorded here so the next reader does not conclude the door is
parsing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---
_Generated by [Claude
Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…two declarations (objectstack-ai#19130)

Fixes objectstack-ai#18605

Clause-②: yes — carrier: the changeset
`.changeset/18605-enable-on-install-one-authority.md`
(`@objectstack/spec`, minor). Three published declarations' stated
meaning moves; the accept set does not move at all.

## Ruling bullet 1 was already discharged by PR objectstack-ai#18752 — this PR did not
skip it

Batch objectstack-ai#153 item 5, letter 1 carries two bullets. The first one — the
install door writes the registry row's `enabled` from `enableOnInstall
?? true` — landed with PR objectstack-ai#18752 (card objectstack-ai#18058), and this claimant
re-derived that against the merged diff before planning, rather than
inheriting the card's text:

- `packages/runtime/src/domains/packages.ts` reads `body.enableOnInstall
=== false` off the WRAPPED body, flips the registry row through the same
call `PATCH /packages/:id/disable` uses, and then persists the row the
door returned to the durable state file.
-
`packages/runtime/src/domains/packages-install-enable-on-install.test.ts`
pins it, header and all: `false` installs disabled in all three records,
`false` also moves `status`, `true` installs enabled, an absent key
defaults to `true`, the disable is durable across a restart, and a
re-install with `true` clears it.
- Both still exist on `origin/main` as of this branch's merge (asserted
by quoted-exact-name `git grep` against `origin/main`).

So the card's own premise — "honoured by no handler" — is **false for
the authority's door on today's `main`**, and this PR carries bullet 2
and only bullet 2.

## Bullet 2, verbatim

> **One authority**: the request contract in `package-api.zod.ts`. The
claimant re-reads the two other declarations — a copy of the request key
is folded to a reference; a declaration that means something else (a
stored-row field, a marketplace listing attribute) stays and says so. ⛔
**No silent unification of three published declarations.**

## The read-out, per declaration

All three read `enableOnInstall: z.boolean().default(true)` with
byte-identical description text, so identical shape carried no
information. What distinguishes them is the request each sits on and the
door that serves it.

| declaration | what it is | disposition |
|---|---|---|
| `api/PackageInstallRequestSchema` | the HTTP wire contract of `POST
/api/v1/packages`, the door that honours the key | **the one authority**
|
| `kernel/InstallPackageRequestSchema` | the request type of the
in-process protocol primitive `ObjectStackProtocol.installPackage` | **a
COPY of the request key** — referenced |
| `marketplace/MarketplaceInstallRequestSchema` | the marketplace
channel's install-from-listing request, served by the control plane |
**means something else** — stays, and says so |

⚠️ Both of the ruling's parenthetical guesses were falsified by the
re-read, and that is recorded rather than quietly worked around. The
kernel declaration is **not** the stored-row field: the stored-row field
is `InstalledPackage.enabled`, a different key in the same file. The
marketplace declaration is **not** a listing attribute:
`MarketplaceListingSchema` does not carry it; it sits on the install
request beside `listingId`.

### The authority — `api/PackageInstallRequestSchema.enableOnInstall`

It is the authority because it is the request contract of the door that
honours the key. Its published description now says so, so a reader of
the reference page can tell which of three rows is the one that acts:
"honoured at POST /api/v1/packages: the installed row's `enabled` is
written from this key". Its doc block carries the map to the other two,
so nobody has to re-derive this reading a third time.

### Re-read ① — `kernel/InstallPackageRequestSchema.enableOnInstall` is
a COPY

Same type, same default, same meaning, restated one layer down on the
in-process protocol primitive. Two measured facts decide it:

- `MetadataProtocol.installPackage` reads `request.manifest` and
`request.settings` and nothing else
(`packages/metadata-protocol/src/protocol.ts`). The key reaches no code
that acts on it there.
- The authority's own door does not forward it down that seam: it calls
`installPackage({ manifest, settings })` and performs the enable/disable
flip itself afterwards, because the durable half must follow the ROW
that door returned rather than the request's intent. That is deliberate
and documented at the call site.

It is therefore a copy, and per the ruling it must not be left
unreferenced. **The reference is documentary in the declaration and
MECHANICAL in a pin**, for a reason that was measured rather than
assumed — see the next section.
`packages/spec/src/api/package-install-one-authority.test.ts` parses the
authority and the copy over one matrix (absent, `false`, `true`, a
string, `null`) and reds on any cell where they disagree, so the copy
can no longer drift from the authority silently.

### Re-read ② —
`marketplace/MarketplaceInstallRequestSchema.enableOnInstall` means
something else

Same words, a different commitment, and the difference is the subject of
the request it sits on:

- Its subject is a marketplace LISTING (`listingId`, `version`,
`licenseKey`, `tenantId`). The authority's subject is a MANIFEST.
Neither body can be sent where the other is expected, which the pin
asserts in both directions.
- Its door is the control plane's `POST /api/v1/marketplace/install`; a
runtime mounts `/api/v1/marketplace/*` only as a read-only proxy to the
configured control plane (`MarketplaceProxyPlugin`).
`docs/design/marketplace-publishing.md` §4.3 spells the flow out: the
channel fetches the artefact and validates the licence and only THEN
maps what it holds into a platform install. So this key is what a caller
asks the marketplace to request on its behalf — one translation upstream
of the door key.
- It is a different party's contract on a different release cadence: the
declaration was `cloud/MarketplaceInstallRequest` before it moved into
this namespace (`packages/spec/scripts/lib/renamed-defs.ts`). One shared
declaration would let a narrowing at the platform door silently narrow a
control-plane contract that no PR in this repo can see.

So it stays, and its published description now says which of the two it
is.

## The prescription that is not executable as written, and the
measurement

"A copy of the request key is folded to a reference" reads naturally as
`enableOnInstall: PackageInstallRequestSchema.shape.enableOnInstall` at
the copy's site. **That spelling is not available in this direction, and
it is not a style preference — it is measured.**

The authority sits ABOVE both copies in the module graph:
`PackageInstallRequestSchema` is built from `ManifestSchema` and
`InstalledPackageSchema` (declared in `kernel/package-registry.zod.ts`)
and from `ArtifactReferenceSchema` (declared in
`marketplace/marketplace.zod.ts`). A reference from either copy up to
the authority is therefore an import cycle, and it is not a cycle the
`lazySchema` proxy absorbs: under `OS_EAGER_SCHEMAS=1` — the mode
`gen:schema`, `gen:authorable-surface-base` and
`check:authorable-surface` run in — the factory bodies evaluate at
module load and the cycle dies.

Measured on this branch, both directions, each against a control that
passes on the unmodified tree:

| leg | command | result |
|---|---|---|
| control · kernel | eager load of `kernel/package-registry.zod`,
unmodified | exit 0, `enableOnInstall` defaults to `true` |
| treatment · kernel | the same load with `enableOnInstall:
PackageInstallRequestSchema.shape.enableOnInstall` | exit 1,
`ReferenceError: Cannot access 'InstalledPackageSchema' before
initialization`, raised from `api/package-api.zod.ts` through
`lazySchema` |
| control · marketplace | eager load of `marketplace/marketplace.zod`,
unmodified | exit 0, `enableOnInstall` defaults to `true` |
| treatment · marketplace | the same load with the same structural
reference | exit 1, `ReferenceError: Cannot access
'ArtifactReferenceSchema' before initialization` |

Under the default lazy mode both treatments load fine, which is the
dangerous half: the runtime would be green and the generator would die.

Both treatments were reverted and the revert proven by blob hash against
`HEAD` (`git hash-object` equal, `git diff HEAD` empty) before anything
else was written.

⇒ The only structural fold available would be to move the key's literal
into a module BELOW both copies and have the authority import it. That
was deliberately not taken unilaterally: it moves the declaration out of
`package-api.zod.ts`, which is the file the ruling names as the one
authority, so it changes the ruling's own terms. It is recorded as an
open question below rather than performed.

## What moved on the published surface

- Three `.describe()` strings — the text `content/docs/references/**`
renders, and the only half of a doc block an author reading the
reference page ever sees.
- Three doc blocks in the source.
- The four generated reference pages that follow from those strings
(five table rows; the authority appears twice because
`PackageInstallBody` renders its wrapped branch).
- One new test file.

**What did not move:** no key added, removed, renamed or retyped, no
default changed. `check:api-surface`, `check:api-surface-declarations`,
`check:authorable-surface`, `check:export-origins` and
`check:declaration-map` are all green with no regeneration — the
`api-surface-declarations` shards this card was flagged for do **not**
move, because a `.describe()` change does not change a `.d.ts` type.

## Verification

Run on the merged tree (`git merge origin/main` through
`scripts/pm/os-regen-merge.sh`), exit codes captured before any pipe.

| what | result |
|---|---|
| `pnpm --filter @objectstack/spec test` | 496 files / 14536 tests
passed |
| `pnpm --filter @objectstack/spec typecheck` | exit 0 |
| `pnpm --filter @objectstack/spec check:generated` | all 16 generated
artifacts up to date |
| `eslint . --no-inline-config --format json` | 6879 files reached by
eslint's own config, 0 errors, 0 warnings — the union, not a narrowing |
| gate families derived by `scripts/pm/dispatch-gates.mjs` and run | see
below |

Gate families run locally, all exit 0: `check:nul-bytes`,
`check-spec-docblock-symbol-anchors`, `check:duration-unit-keys`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check-adr-0087-registration --base origin/main`,
`check-changeset-no-major --base origin/main`, `check-empty-changeset
--base origin/main`, `check:changeset-gate-self-tests`,
`check:pm-widening-tells`, `check:exported-any`,
`check:dual-source-exports`, `check:entry-nameability`,
`check:variant-docs`, `check:empty-state`, `check:llms-txt`,
`check:browser-reachable-entries`, `check:skill-examples`,
`check-doc-frontmatter`, `check-docs-section-name`,
`check-doc-route-spelling --advisory`, `docs-audit/check-affected-docs`,
`check:doc-anchors`, `check:docs-single-h1`.

`check:skill-examples` first exited 1 on a build prerequisite
(`@objectstack/client-react` had no `.d.ts`), not on this diff; after
`pnpm --filter '@objectstack/client-react^...' build` it exits 0 over
258 prose examples. The remaining families the derivation names are CI's
farm and are not claimed here.

**Reverse verification of the new pin** — the fix was committed first,
the mutation landed through `scripts/ablation-replace.mjs` (anchor hit
x1, blob `64a17a8bc364` to `f2cacf8c304b`), and the restore was proven
against `HEAD` rather than against an exit code:

- kernel's `enableOnInstall` default mutated `true` to `false`
- predicted direction: the parity cells go red, the rest stay green
- observed: `Tests 2 failed | 10 passed` — the two that fail are the
absent-key parity cell and the same-default assertion
- restored: blob equals HEAD (`64a17a8bc364`), `git diff HEAD` empty

## Acceptance notes

Observed while reading, deliberately not fixed here and not filed:

- `packages/spec/src/contracts/package-service.ts` declares a FOURTH
`enableOnInstall`, on the plain TS interface `InstallPackageInput` for
`IPackageService`. It is outside the ruling's three schemas (not Zod,
not on the authorable surface), and `IPackageService` has no
implementation in this repo — the only place the key is read is an
inline fake inside `package-service.test.ts` (`enabled:
input.enableOnInstall !== false`). Noted, not filed: the interface is a
contract with no consumer here, so nobody is currently misled by it.
Carrier if it ever needs one: whoever implements `IPackageService`.
- `content/docs/api/metadata-api.mdx` documents the install body inline
rather than from the contract, so it will not follow a future change to
it. Noted, not filed: a hand-written page drifting from a schema is not
one of the three filing classes, and no PR or person is presently
heading for that file. Carrier: none.

## Open question recorded for the seat, not answered here

The structural fold is available in exactly one shape: move the key's
single literal into a module below both copies and have the request
contract import it. That would give literally one Zod declaration of the
key instead of a pin holding two in step — but it takes the declaration
out of `package-api.zod.ts`, which the ruling names as the one
authority. Whether the ruling prefers one literal in a lower module or
the authority's file keeping its own literal with a mechanical pin is a
question about the ruling's terms, so it is recorded rather than decided
by the claimant. The current shape is the one that changes nothing the
ruling said.

A second, separate question the re-read surfaced: the copy's own door
(`ObjectStackProtocol.installPackage`) still does not honour the key.
Making it honour the key would be new runtime behaviour at a door the
ruling did not name — it is safe (every present caller omits the key, so
nothing changes today), but it is not this card's to authorise.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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 domain:spec priority:p2 Medium: important, M3 size/xl tests tooling

Projects

None yet

2 participants