Repository navigation
fix(spec,runtime): bind the package-install contract to the door that serves, and honour enableOnInstall - #18752
Conversation
… 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>
📓 Docs Drift CheckThis PR changes 3 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 143 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Contract reviewServed-tier: ① Derived judgmentsMethod: own worktree at the head sha (merge-base J1 — Union disjointness holds; the closedness claim does not (item 1). J2 — 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 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 ( J5 — README acceptance fixture (item 5): the red pin cannot see the README. Reverted J6 — Measurement gap (test:repo) and suites. ② Semver level
③ Boundary flags
FAIL items: F1 — F2 — F3 — F4 — Make the README acceptance fixture able to fail. Add a client-side test (e.g. Implemented-by: VERDICT: FAIL Generated by Claude Code |
席位处置 —— FAIL 收下,补丁轮已排队等席位槽位,⛔ 不是搁置
达档复核记录逐字落档于 为什么本席认为这个 FAIL 是对的,而不是复核过严⭐ F1 是一个会持久化的静默错误,不是文档问题。 实测序列: 处理器只写 F3 与 J1:本席特别认可这一条的读法PR 的 docblock 写「⛔ A union of two whole CLOSED forms」,而复核实测 ⭐ 复核同时给了正确方向的约束:⛔ 不要把它改成 strict —— 那会拒绝门今天接受的 body,正是路线 A 禁止的方向。要改的是那句话,不是那个 schema。 这条区分本席认为是整份复核里最要紧的一句。 F2:本卡「KEEP 裸 manifest 形态」这个决定的承重证据被证伪了施工席的 M3 引 而 PR 的测试用例标题写「parses the BARE manifest form the runtime door drives send」,注释写「posts exactly this」,实际断言的 body 却多了一个 ⇒ 每一个被测量到的裸形态发送者,都落在裸形态声明之外;而声称固定住它们的夹具,并不是它们的 body。⛔ 这不是措辞问题,是那个 KEEP 决定的证据链断了。 处置与时序
一条本席欠复核的谢复核按本席的要求跑了 Generated by Claude Code |
…ckage-install-door-enforce
…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>
…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>
…ckage-install-door-enforce
…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>
Contract reviewServed-tier: ① Derived judgmentsMethod: linked worktree at the head sha in this session's scratchpad (the shared 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 J2 — F2 remedy holds. The two drives' real bodies at head are J3 — F3: the sentence changed, the schema did not. J4 — F4 can now fail. The branch's own J5 — Both merges dropped no side. 49ebeda (18 first-parent commits from main): the only path both sides touched is J6 — Ledger header equals the generator. J7 — Orphan and tree. Process table holds no J8 — Suites and gates. NOT MEASURED: ② Semver level
③ Boundary flags
FAIL items: F1b — Implemented-by: 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>
Contract reviewServed-tier: 99/99 ⭐ 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 ( ① Derived judgments1. 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 ⭐ 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 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 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 — 6. CI at this head: 35 check runs, 33 success / 2 skipped / 0 failure, all seven required contexts green. ② Semver levelUnmoved. 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
NOT MEASUREDA driver-free ⭐ One reading the seat's own note did not carry: a driver-free Implemented-by: VERDICT: PASS Generated by Claude Code |
…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>
…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>
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
PackageInstallRequestSchemawas declared, published and bound toPOST /api/v1/packages/install— a path the composed runtime mounts nowhere — whilePOST /api/v1/packages, the door that actually answers201, 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 undeclaredoverwrite, a declared-and-unhonouredenableOnInstall, and a published README example that cannot parse.What this PR does
POST /api/v1/packages; remove the phantom bindingPackageApiContracts.installPackage.pathis now/api/v1/packages; the module route table, the section heading and the@examplefollow. No published route row named the phantom (see M1).overwrite, and the door must honour itPackageInstallRequestSchema.overwrite?: boolean. The honour half was already true and is now measured and pinned (M2) — the missing half was the declaration.PackageInstallBodySchema= a union of the wrapped request and a bareManifestSchema— two DISJOINT forms, of which only the bare one is closed.5720011982, F3):PackageInstallRequestSchemais az.objectin strip mode, so the WRAPPED branch is not closed —{ manifest: SDK, bogus: 1 }is accepted andbogussilently dropped. ⛔ It is deliberately NOT made.strict(): that would refuse bodies the door serves today. (M3)manifestslot is named as the AUTHORING stage in its declaration, its.describe()and the doc block, with the asymmetry againstInstalledPackageAtEitherStageSchemastated: the read doors are reached at two stages, this wire at one.enableOnInstall: honour it or remove its three declarationsMeasurements
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 base62bce5c297resolves in 3 files:packages/spec/src/api/package-api.zod.ts, its own test, and the auto-generatedcontent/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 controlpackages/zzznosuchrouteresolves in 0, EXIT=1. Source reading agrees:handlePackagesRequestbranches onparts.length === 0(GET/POST),=== 1(GET/PATCH/DELETE),=== 2and=== 4— there is no one-segment POST branch, so/packages/installfalls through unhandled, andpackages/rest's registrar mounts onlyPOST /api/v1/packages/publish.check:route-envelopeEXIT=0 andcheck:route-ledger-censusEXIT=0 after the rebind.M2 —
overwritewas honoured and undeclared, which is the wrong half to be missing.packages/runtime/src/domains/packages.tsreadsbody?.overwrite === true || query?.overwrite === 'true' || query?.overwrite === trueand 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-wordgit grep -cw overwriteonpackage-api.zod.tsat the merge base = 0, EXIT=1. LIT control, same exact word, same instrument, overpackages/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 matchesoverwriteExistingand 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 readsconst manifest = body.manifest || body, and two drives do exercise it with no wrapper:packages/runtime/src/package-door-namespace-conflict-code.test.tsposts{ id, name: id, namespace, version }andpackages/runtime/src/domain-handler-registry.test.ts:582posts{ 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 newPackageInstallBodySchema(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 extratype: '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
enableOnInstallis 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 forPATCH /packages/:id/disable:SchemaRegistry.disablePackage(id)(packages/objectql/src/registry.ts:4328) andsetPackageDisabled(packages/runtime/src/package-state-store.ts:168, imported atpackages.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, notype, and alabelkey the closed manifest surface refuses by name) — and it also failed at the live door, which answered400 Package id is requiredfor want of anid. 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, theenableOnInstallcase and theoverwritecase) 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/installpath, the bound input being the body schema,overwritesurviving 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—enableOnInstall: falselands disabled in all three records (the returned row, the registry, the durable state file); for an unseeded idtrueand 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,trueand 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:56reserves the body to the PR-open write, so the round names the wording and the seat writes it). The fenced block below was run at2dcce3e2c6and 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, at14f9b00ccbd: spectest487 files / 14077, spectest:repo31 / 536, spectypecheckEXIT=0; runtimetest267 files / 3696 passed + 1 skipped, runtimetest:repo2 / 69, runtimetypecheckEXIT=0; clienttest46 / 549, clienttypecheckEXIT=0 (@objectstack/clienthas notest:reposcript — read from itspackage.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 against0a02ad08d5fis 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.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 adist/— both are in-package source imports — so no rebuild leg applies.packages/spec/src/api/package-api.zod.tsto the merge base. Anchors before/after:overwrite: z.boolean().optional()1 to 0,PackageInstallBodySchema5 to 0,path: '/api/v1/packages/install'0 to 1. Suite EXIT=1, 14 failed / 42 passed. Restored:git diff HEAD --statempty,git status --porcelainempty, on-disk hash9af3a3aba8equals the HEAD blob.packages/runtime/src/domains/packages.tsto the merge base. Anchors before/after:body?.enableOnInstall === false1 to 0,const wrapped = manifest !== body1 to 0. Suite EXIT=1, 2 failed / 3 passed — and the two that failed are exactly the twoenableOnInstall: falsecases, while thetrue, 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 hash8717e40257equals the HEAD blob.Direction in both cases was turns red, as predicted before running.
Gates
pnpm --filter @objectstack/spec check:generatedEXIT=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-surfaceand the JSON-schema manifest were regenerated by the build. Every surface diff is purely additive:PackageInstallBody{,Parsed,Schema}added,PackageInstallRequest:overwriteadded, 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 familiesdispatch-gatesderives 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:
PackageInstallRequestSchema(api),InstallPackageRequestSchema(kernel/package-registry.zod.ts) and the marketplace one — still differ only inartifactRef. This PR touched the first and left the other two alone, because the ruling's scope is the live HTTP door and the other two are other surfaces' contracts. Carrier: whichever PR next has to choose among them. Carrier if that never happens: none.enableOnInstallis still declared on two other schemas (kernel/package-registry.zod.ts:283,marketplace/marketplace.zod.ts:494) whose own doors this PR did not measure. The ruling's honour-or-remove applies to the install door, which now honours it; whether those two doors honour theirs is a separate measurement on a separate surface. Carrier: a card about those doors, if one is ever opened. Carrier today: none.platformVersionandartifactRefare declared on the install request and read by no handler at the live door. They are the same shape as theenableOnInstalldefect and were left untouched because the ruling namesenableOnInstalland forbids widening the scope; they are named here so the next reader does not have to re-measure. Carrier: this note.@objectstack/client'spackages.get/listdeclare the AUTHORING stage while the door they call is declared at either stage — two declarations one layer apart now disagree #17536 remains open and is not addressed here; finding(spec): AssembledPackageBodySchema declares callable and custom branches an inert-JSON artifact cannot hold, and every schema embedding it loses its JSON Schema #17518 and [finding] Declaring the assembled stage grew@objectstack/spec/api's browser bundle by +19.4% gzipped, and no rule watches it —./apiis in the ledger'sunjudgedlist #17535 were not touched.Generated by Claude Code
Generated by Claude Code