Skip to content

spec: one authority for enableOnInstall, and a read-out of its other two declarations - #19130

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-18605-enable-on-install-one-authority
Sep 20, 2026
Merged

os-sam merged 3 commits into
mainfrom
claude/issue-18605-enable-on-install-one-authority

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #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 #18752 — this PR did not skip it

Batch #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 #18752 (card #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

…ne authority

`enableOnInstall` is declared in three published schemas. The install door's
request contract (`api/package-api.zod.ts`) is the one authority: it is the
contract of the door that honours the key. The other two are re-read here.

- `kernel/InstallPackageRequestSchema` is a COPY of the request key, restated
  on the in-process protocol primitive. It is held to the authority by a
  parity pin rather than by a structural reference: the authority sits above
  `kernel/` in the module graph, so `…Schema.shape.enableOnInstall` spelled
  there is an import cycle that dies under `OS_EAGER_SCHEMAS=1`.
- `marketplace/MarketplaceInstallRequestSchema` means something else and
  stays: its subject is a marketplace listing, its door is the control
  plane's, and its key is one translation upstream of the door key.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 9 documentable anchor(s).

10 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))
  • content/docs/api/environment-routing.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/api/metadata-api.mdx (via /packages/:id/disable (route, a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/getting-started/examples.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/kernel/services-checklist.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/ui/apps.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))

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

  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages (route, a path literal in a comment in InstallPackageRequestSchema; a path literal in a comment in PackageInstallRequestSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 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 9bb059dbfe1fdf6443cb9f76266d2a0ebff71781 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9bb059dbfe1fdf6443cb9f76266d2a0ebff71781

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 18, 2026
Merged via the queue into main with commit 596090e Sep 20, 2026
44 checks passed
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…Install` (objectstack-ai#19338)

Fixes objectstack-ai#19277

Clause-②: no

`InstallPackageRequestSchema.enableOnInstall`
(`packages/spec/src/kernel/package-registry.zod.ts`) is the request
contract of the in-process `ObjectStackProtocol.installPackage` /
`MetadataProtocol.installPackage` primitive. The implementation read
`request.manifest` and `request.settings` and nothing else, so a caller
that asked for `enableOnInstall: false` got an ENABLED install — no
refusal, no warning, no effect. Declared but not enforced on a published
option, which ADR-0049 (enforce-or-remove) and Prime Directive objectstack-ai#10
refuse outright.

Ruling batch objectstack-ai#153 item 5 letter 1 (objectstack-ai#18605, record `5724940709`) kept
this declaration as a COPY of the HTTP request key with the SAME
meaning, so the disposition is **enforce, not retire**.

## What changed

`MetadataProtocol.installPackage` now applies the same rule the HTTP
door applies, through the same registry verbs `PATCH
/packages/:id/enable` and `PATCH /packages/:id/disable` use:

| `enableOnInstall` | effect |
|:--|:--|
| `true` | `enablePackage` — clears a disable, including a boot-seeded
one |
| `false` | `disablePackage` — the row and its `status` both move |
| absent | no lifecycle call at all; the row the registry returned
stands |

`=== true` / `=== false`, never a truthiness test and never a `??`
default — the three states are the contract. A non-boolean value is read
as absent rather than coerced.

## ⚠️ The card's mechanism sentence was stale; the matrix was taken from
the tree

The card (written 2026-09-20T09:06Z) asks for 「the registry row's
`enabled` (and `status`) follow `enableOnInstall ?? true` on install
**and on re-install**」. PR objectstack-ai#19291 (`4fef271b7`, 2026-09-20T11:10Z)
re-ruled exactly those cells under maintainer ruling batch objectstack-ai#157 item 5
letter C (「缺省 = 保持,有旗 = 设置」), which is younger than this card's own
ruling. `?? true` on re-install is precisely what the HTTP door
**stopped** doing.

The direction 「honour it the way the HTTP door does」 is self-updating
and still governs, so the matrix below was read off
`packages/runtime/src/domains/packages-install-enable-on-install.test.ts`
on `origin/main`, not off the card's prose. The four cells checked, and
they match the dispatch's table exactly:

| line | case | on the tree |
|:--|:--|:--|
| `:154` | ABSENT flag, FRESH install | enabled |
| `:233` | `[objectstack-ai#18877 re-ruled]` re-install, flag ABSENT | **PRESERVES**
the disable |
| `:262` | re-install, `enableOnInstall: true` | clears the durable
disable |
| `:278` | `[objectstack-ai#18877 re-ruled]` BARE re-install | **PRESERVES** it too |

⛔ One HTTP-door cell has no analogue at this seam: the BARE body form (a
manifest posted as the whole body) does not exist in-process —
`InstallPackageRequest` always carries `manifest` as a field. What is
pinned instead is the third state's boundary: a non-boolean value is
read as ABSENT.

## ⛔ What this seam does NOT write

The runtime's durable disabled-package file is keyed by **environment**
(`setPackageDisabled(environmentId, id, disabled)`,
`packages/runtime/src/package-state-store.ts`), and an
`InstallPackageRequest` carries no environment — so that key cannot even
be formed here. The module also lives in `@objectstack/runtime`, which
depends on `@objectstack/metadata-protocol` and not the other way round.
The HTTP door owns that half and writes it from the row it returned.

So `enableOnInstall` through the in-process primitive moves the
**registry row** — what every in-process reader serves from — for the
life of the process. This is exactly the scope the card's acceptance
names (「registry row + status」). It is stated in the code, in the
changeset and here rather than left to be rediscovered; see acceptance
notes for the follow-up it earns.

## No behaviour change for any caller on the tree

The card's own measurement, re-verified rather than inherited. Radius:
`packages/**`, `examples/**`, `apps/**` in this repo, at `2c8e2667c`.

- `packages/runtime/src/domains/packages.ts:769` —
`protocolSvc.installPackage({ manifest, settings: body.settings })`. The
key is deliberately not forwarded; the door performs the flip itself.
- `packages/metadata-protocol/src/protocol.ts` (`duplicatePackage`) —
`this.installPackage({ manifest: dupManifest })`. Flag absent.

Those are the only two call sites. ⇒ confirmed: no existing caller sets
the key, so this is observable only to a caller that sets it — one that
until now got silence.

## Verification

**Gates** — ⛔ not a list taken on trust: derived from the actual changed
files with `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands`, each exit code landed to a file
before any pipe, then reconciled:

```text
✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run,
  0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3).
```

All 61 exit 0, measured at `2c8e2667c`. Three of them
(`check:dual-build-cjs-loads`, `check:lean-entry-closure`,
`check:type-check-debt`) first answered **exit 3 = PREREQUISITE NOT
MET**; that was cleared with a full workspace build and they were
re-run, ⛔ never read as a pass.

**Tests**

| run | result |
|:--|:--|
| `pnpm --filter @objectstack/metadata-protocol test` | 2596 passed, 19
skipped (185 files) |
| `pnpm --filter @objectstack/objectql test` | 5037 passed (303 files) |
| `pnpm --filter @objectstack/{metadata-protocol,objectql} typecheck` |
pass |
| `pnpm --filter @objectstack/runtime exec vitest run
src/domains/packages` | 243 passed (16 files) — the HTTP door is unmoved
|
| `pnpm lint` (repo-wide `eslint . --no-inline-config`) | pass |

**Ablation** — the new pin is proven able to fail. `packages/objectql`
resolves `@objectstack/metadata-protocol` through its `exports`, i.e.
`dist/`, with no vitest alias (it is a `KNOWN_UNALIASED_TEST_IMPORTS`
entry), so the mutation was rebuilt and proven present in the artifact
before the run's colour was read:

```text
mutate   ablation-replace: anchor x1 -> x0, blob e7a7874 -> 7d197b0f7299
rebuild  pnpm --filter @objectstack/metadata-protocol build
dist     ✓ marker present in 2 built files — the ablation is live in the artifact the suite consumes
run      Tests  7 failed | 5 passed (12)            ← direction: turned RED, the ordinary direction
restore  ✓ restored: blob == HEAD (e7a7874) and `git diff HEAD` is empty
rebuild  pnpm --filter @objectstack/metadata-protocol build
dist     ✓ marker absent from all 24 built files
tree     ✓ working tree clean against HEAD
```

The 5 cases that stay green under the ablation are the control legs —
fresh-absent, fresh-true, the non-boolean cell, seeded-absent and the
unseeded control — none of which depends on a flag arm. Nothing of the
ablation is left in the tree; the mutation script carried a `trap` on
`EXIT INT TERM` with absolute paths.

## Acceptance notes

**1. ⭐ A published description is falsified by this PR, and it is fenced
out of this card.**
`packages/spec/src/kernel/package-registry.zod.ts:325` ships this
`.describe()` text, which reaches the published reference page
(`content/docs/references/kernel/package-registry.mdx:187` and
`content/docs/references/api/protocol.mdx:1913`):

> Whether to enable immediately after install — restates the
install-door request key, whose one authority is
api/PackageInstallRequest; **this protocol primitive does not read it**

The doc block above it says the same at length (「This contract's own
implementation does not read the key」), and
`packages/spec/src/api/package-api.zod.ts:305` carries a second copy. As
of this PR all three are **false**. They were written by objectstack-ai#19130, which
merged at 11:10Z — two hours *after* this card was filed — so the card's
author could not have fenced around them.

⛔ Not fixed here: the card and the dispatch both fence `packages/spec`
out (「the declaration half belongs to objectstack-ai#19273」), and editing a
`.describe()` pulls in the whole spec generated-artifact family
(`gen:schema`, `gen:docs`, `check:generated`) plus a second package's
changeset — a new verification surface, so the bounded-in-place-fix
exemption does not hold. It belongs to **objectstack-ai#19273**, whose open question
is already 「once the runtime honours 「缺省 = 保持」, what should the
published `enableOnInstall` declaration say?」. Recorded here and in the
report so it is not rediscovered as drift. No gate goes red on it:
`check:docs` compares the generated page against the describe, and both
still agree with each other.

**2. The durable half of the in-process door, noted not filed.** A
caller that sets `enableOnInstall: false` in-process now gets a disable
that is real in the registry and absent from the runtime's disable file,
so a restart re-enables it. That is narrower than the pre-PR gap (where
the key did nothing at all) but newly reachable, and it cannot be closed
at this seam: the record is keyed by an environment the request does not
carry. Closing it means either giving `InstallPackageRequest` an
environment or giving the caller the durable verb — a contract decision,
not an implementation one. Who would meet this: only a caller that sets
the key, of which there are none on the tree today.

**3. `.changeset/18605-enable-on-install-one-authority.md` (unreleased)
states 「Its published description now records that this layer does not
read it」.** If it and this PR's changeset ship in the same release, one
release's notes will say both. Belongs with finding 1, in objectstack-ai#19273.

Nothing else was touched: this diff is
`packages/metadata-protocol/src/protocol.ts`, one new test file under
`packages/objectql/src/`, and the changeset.

---
_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
…cute, and retire the two they never did (objectstack-ai#19364)

Part of objectstack-ai#17667

Clause-②: yes

Ruling of record: comment `5651023067` — director seat, decision batch
objectstack-ai#126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to
`1(2)·2A·3A·4B`. **Route 2**: the door's declaration and its reads are
aligned. ⛔ Not re-adjudicated here.

`Part of`, not `Fixes`, and the reason is measured rather than cautious
— see **Why this does not close the card** below. The dispatch asked for
`Fixes objectstack-ai#17667`; that instruction is overridden by the standing rule that
a merge which should not close a card uses `Part of` and names the half
it leaves. Flagged rather than silently chosen.

## STEP ZERO first — the ruling's own precondition did NOT stop the work

Ruling item 4 makes the dispatch's first act a stop condition: if a
platform-wide cursor convention already exists and `/packages` is the
only holdout, route 1 **by reuse** is re-priced and the taker stops.
Measured in this worktree at `81e12e1`, 2026-09-20T12:05Z:

- **No shared pagination helper reaches any REST list door.** The only
cursor codec in the tree is `encodeStorageListCursor` /
`decodeStorageListCursor`
(`packages/spec/src/contracts/storage-service.ts`) — the
storage-**adapter** `list()` contract. Imports of it outside
`service-storage` and its own contract file: **zero**. Imports of any
`Cursor`-named symbol by `packages/runtime/src/**` or
`packages/rest/src/**`: **zero**.
- **Lit controls on the same greps, so those zeros are readable.**
`parseIntegerParam` (`packages/runtime/src/query-param.ts`) IS found
shared across two dispatcher domains, and `refuseUnknownQueryParams` IS
found shared across two `packages/rest` files. The search finds shared
helpers when they exist.
- **`/packages` is not the only holdout — it is one of four.**
`ListExportJobsRequestSchema`, `ListAiConversationsRequestSchema` and
`ListRunsRequestSchema` all declare `limit` and/or `cursor`; none
paginates. `GET /automation/:name/runs` even validates `cursor` at the
boundary and then returns `{ runs, hasMore: false }`, with its own
comment recording that "today's engine ignores the option entirely" —
the same shape as this door, one domain over.
- **The platform's travel is the other way.**
`api/ListNotificationsRequest:cursor` (objectstack-ai#6361) and `data.query.cursor`
(objectstack-ai#4286) were both retired before this one.

⇒ the stop condition is false in both of its conjuncts. Proceeding to
items 1 and 3 was measured, not assumed.

## Ruling item 1 — declare what the doors already execute

| door | parameter | read site | now declared on |
|---|---|---|---|
| `GET /api/v1/packages` | `type` | list branch, `manifest.type`
equality | `ListInstalledPackagesRequestSchema` |
| `GET /api/v1/packages/:id` | `version` |
`readRequestedVersion(query?.version)` |
`GetInstalledPackageRequestSchema` |
| `DELETE /api/v1/packages/:id` | `keepData` | uninstall branch |
`UninstallPackageApiRequestSchema` |
| `POST /api/v1/packages` | `overwrite` | install branch | **already
declared — see below** |

No accept set moves: the doors served all four before and serve them
identically now.

Each declaration is measured from the handler's actual read, not from
the card:

- **`type`** is an open `z.string()`, deliberately not an enum. The door
compares `manifest.type === query.type` on any non-empty value, and
`ManifestSchema.type` is no shared vocabulary — a narrower declaration
would state a rejection this wire does not perform. An unmatched value
is not an error; it selects nothing.
- **`version`** is a plain string. `latest` means "the installed row"
and is equivalent to omitting the key; comparison is exact string
equality against `manifest.version`, with no semver-range semantics, and
the id is resolved first so an unknown id keeps its existing 404
wording. All of that is in the key's docblock so the next reader does
not have to open the runtime.
- **`keepData`** is declared boolean, and the docblock records the two
spellings the wire actually honours — `keepData=true` and `keepData=1` —
and warns that anything else, `keepData=yes` included, reads as absent
and DROPS the tables. Widening the door's own comparison would be a
runtime change this declaration is not.

### ⚠️ Premise drift: `overwrite` was already discharged, by the PR that
unblocked this card

The card's body (2026-09-11) lists `?overwrite=` as read-and-undeclared.
That is no longer true. PR objectstack-ai#19130 merged 2026-09-20T11:11:16Z — the same
landing this card had been serialised behind — and it declares
`overwrite: z.boolean().optional()` on `PackageInstallRequestSchema`,
with a docblock that already names the `?overwrite=true` query spelling.
One quarter of ruling item 1 needed nothing. **No edit was made for
it**, deliberately: re-declaring it would have been churn, and the
existing declaration is better than one written from the card.

## Ruling item 3 — retire `limit` and `cursor`, `.default(50)` included

Both keys are `retiredKey()` tombstones, not deletions. The schema is
not `.strict()`, so a bare deletion makes Zod silently strip whatever a
generated client keeps sending — a clean parse and a parameter that
never takes effect, which is this card's own defect moved one layer down
(ADR-0104). Writing either key is now a `tsc` error and a parse error
carrying the prescription.

The prescription names the removed default specifically, because that is
the load-bearing half: a reader of the published schema was entitled to
believe an unparameterised list is capped at 50 rows, and it has never
been capped at all.

**The retirement kit, and the two entries it deliberately does NOT
have.** Precedent hunted and followed:
`api/ListNotificationsRequest:cursor` (objectstack-ai#6361) is the same shape one
route over — an HTTP-only request key retired with a tombstone and a D3
semantic entry. Zone 2 flagged this precedent as unverified by the seat;
it exists, and this change copies it.

- `RETIRED_KEYS_BY_MAJOR[18]` — two entries, one file each, generated
into `migrations/registry.ts` by `gen:migration-registry`.
- D3 semantic entry `packages-list-pagination-retired`, carrying
`surface` / `replacement` / `reason` / `acceptanceCriteria` to
`spec-changes.json`, the generated upgrade guide and `os migrate meta`.
- Registered at **18, not 17**, per the `ui/ListView:pageName` and
`security/ObjectPermission:allowPurge` convention: the removal ships on
the 17.x line as a minor, and the prescription lives at the major
boundary where `migrate meta` users look. The guidance string says
`17.5.0`, the shipping version, matching `view.pageName`.
- **No D2 conversion**, and the asymmetry is the point: a conversion
rewrites an authored source or a stored `sys_metadata` row, and this
shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and
nothing persists one. The `os migrate meta` house sentence is therefore
correctly absent from the prescription; the pin only judges
prescriptions that name the command.
- **No `acceptRetiredDefaultResidue` stage**, for the same reason one
layer along. That helper exists for a retired default the published
toolchain materialized into built artifacts. Nothing has ever parsed
this schema, so the `.default(50)` reached no artifact and there is no
residue population. The `authorable-defaults/api.json` line simply
leaves with the key — `DEFAULT_CHANGES_BY_MAJOR` excludes retirements by
name, and `check:authorable-surface` accepted it without one.
- **No liveness-ledger row** to touch: `liveness/api.json` is the `api`
METADATA type's ledger, not the spec `api/` category. Zero occurrences
of `ListInstalledPackages` in it.

**Ratchet readings, stated because their direction is route-dependent:**
`authorable-surface/api.json` gains two `[RETIRED]` rows and three new
keys; `authorable-defaults/api.json` loses exactly the `= 50` line;
`api-surface/` is unchanged, which is correct for a key-level narrowing
on a surviving def.

### `hasMore` is now true by construction, and the comment says so

`hasMore` stays the constant `false` it already was. With no `limit` and
no `cursor` to ask with, nothing can request a page, so there is never a
next one to announce. That is recorded at the response declaration — the
return site itself is in `packages/runtime/src/domains/packages.ts`,
which this dispatch is fenced off — with an explicit warning against
"fixing" the constant back into a computed value before a request-side
way to ask exists. Pinned by a test.

## Why this does not close the card

Ruling item 2 — `enabled` implemented in
`packages/runtime/src/domains/packages.ts`, one filter line in the shape
`status` already has — is assigned by the ruling to the **cli seat's
sibling PR** and is fenced off this dispatch. Measured on the merged
`origin/main` at `2277d1f`, 2026-09-20T13:30Z: `query?.enabled` occurs
**0** times in that file; control on the same file, `query?.status`
occurs **1** time. So `enabled` is still declared-and-unread after this
PR, which is one live instance of the very class this card names.

That is recorded in the schema docblock rather than glossed, and it is
why the closing line is `Part of`. PR objectstack-ai#19326, which held that file
during dispatch, turns out to be the manifest-`version` change for
objectstack-ai#19120 and has merged; it is not the `enabled` sibling.

## Verification

Readings taken in this worktree; the gate union below was run after the
final commit, at `80937f5`.

- **Gate family, derived from the real changed paths**
(`scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived after the merge): **107 derived
· 104 run green · 3 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with
`--ran` carrying each exit code captured before any pipe. The three NOT
MEASURED are the gates' own `exit 3` PREREQUISITE NOT MET:
`check:dual-build-cjs-loads` (wants a whole-repo build),
`check:type-check-debt` (a re-measure, which exits 3 by design and is a
maintainer's act to act on), and `check-plugin-teardown-shape
--self-test` (wants an unshallow checkout). None is a finding.
- `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts
up to date**, at the final head.
- `pnpm --filter @objectstack/spec test` — **501 files / 14662 tests
passed**. `pnpm --filter @objectstack/spec typecheck` — clean.
- `check:test-typecheck` GRADUATED `src/api/package-api.test.ts`: its
two recorded `TS6133` unused-import errors are gone because the new
tests use both symbols, so the shrink-only ledger entry is deleted in
this PR, as that ratchet requires.
- **Reverse verification (one-off, not left in the tree).** The `limit`
tombstone was replaced on disk with its old
`z.number().int().min(1).max(100).default(50)` via
`scripts/ablation-replace.mjs`, which proved the write landed (anchor 1
→ 0, blob `de8722127dc3` → `10542945489d`) before running anything.
Direction observed: **red**, 2 failed / 68 passed — both the
prescription pin and the absence pin fire. Restore verified by blob
identity against `HEAD` and an empty `git diff HEAD`, by the tool, not
by an exit code.
- **Absence sweep, tree-scoped, with lit controls.** Authoring sites for
`limit` / `cursor` on this request shape outside the new registry
entries: **zero**; `packages.list(` calls passing either: **zero**.
Controls: `overwrite` IS found in the same spec file (10 hits) and
`packages.list(` IS found across five files by the same pattern shape.
The first-party SDK already declares `list(filters?: { status, type,
enabled })` — no `limit`, no `cursor` — so unlike objectstack-ai#6361 there is no
shipped producer to delete alongside the key.

## Acceptance notes

Out of scope, noted and deliberately not filed:

- **`gen:api-surface-declarations` output was not stable across builds
of identical sources**, and it cost this run a wrong turn worth
recording. Build objectstack-ai#1 of the unchanged `ui` sources emitted one
enum-member ordering, build objectstack-ai#2 emitted another; 184 lines of
`api-surface-declarations/ui.txt` flipped between them, and a single
control build at BASE reproduced BASE — which made one sample look like
proof that my diff caused the churn. It did not. The correct reading
needed three builds. **This finding has no surviving consumer**:
`origin/main` at `2277d1f` reverted the whole declaration-text snapshot
(objectstack-ai#19024) and deleted `api-surface-declarations/` along with its gate,
which is also the merge conflict this branch resolved by accepting the
deletion. Successor: none. Recorded here rather than filed because the
artefact and the gate that read it no longer exist.
- **Three sibling list doors carry the same declared-not-honoured
pagination shape** — `ListExportJobsRequestSchema` (`limit` with
`.default(20)`, `cursor`), `ListAiConversationsRequestSchema` (`limit`,
`cursor`) and `ListRunsRequestSchema` (`limit`, `cursor`, the last
validated at the boundary and then ignored by the engine, with `hasMore:
false` hard-coded). This is a reproducible contract divergence of
exactly this card's class, on doors this card does not name, and the
handback report carries it with dedupe words for the seat to file. ⛔ Not
filed from here and ⛔ not widened onto this PR.
- The `/packages` dispatcher domain declares no closed query-parameter
set, so an unrecognised name is still dropped rather than refused. That
is route 3, which the ruling considered and refused; noted so a later
reader does not read this PR as having taken it. Successor: whoever
converts the dispatcher domains per the incremental ingress lane.

Landing waits for the seat: this PR is a contract-review carrier and the
seat handles both the carrier and the at-tier review. Nothing here flips
ready, enqueues, arms auto-merge, requests review, or writes a label or
assignee.

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

---------

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

Fixes objectstack-ai#19273

Clause-②: yes

Ruling batch objectstack-ai#210 item 4 · letter A · maintainer 「210 同意」 (`5770455384`,
2026-09-22T02:41Z). The direction was ruled, not chosen here.

## The defect

`packages/runtime/src/domains/packages.ts:1095` has honoured 「缺省 = 保持,有旗
= 设置」 since PR objectstack-ai#19291 landed:

```
const requestedEnabled = wrapped ? body?.enableOnInstall : undefined;
```

`true` calls `enablePackage`, `false` calls `disablePackage`, and an
**absent** key makes no lifecycle call at all — so a package an operator
disabled stays disabled across an upgrade. Verified unchanged on this
branch; the runtime is not touched by this PR.

The published declarations said something else.
`z.boolean().default(true)` resolves absence **at parse time**, so a
request that omitted the key came out of the parse byte-identical to one
that set `true`. The third state did not exist on the published surface
while the door went on acting on it — a declared default the runtime
deliberately stops applying, on a contract this repo does not own both
ends of.

## What changed

All three declarations now spell `z.boolean().optional()`, with the
semantics on the field in **both** the `describe` and the docblock —
absent = keep the row's current lifecycle state; explicit `true` /
`false` unchanged; a fresh install lands enabled:

| declaration | file |
| :--- | :--- |
| `api/PackageInstallRequest` — the authority |
`packages/spec/src/api/package-api.zod.ts` |
| `kernel/InstallPackageRequest` — the copy |
`packages/spec/src/kernel/package-registry.zod.ts` |
| `marketplace/MarketplaceInstallRequest` — a different party's key |
`packages/spec/src/marketplace/marketplace.zod.ts` |

### The executable criterion, both directions

Read off the **built** package (`packages/spec/dist`), not `src/`, at
head `f5b094a96`:

```
api/PackageInstallRequest    | absent => undefined (key in parse output: false) | true => true | false => false
kernel|api/InstallPackageReq | absent => undefined (key in parse output: false) | true => true | false => false
marketplace/MarketplaceInst. | absent => undefined (key in parse output: false) | true => true | false => false
```

The `true` and `false` arms are **re-read after the change on all three
declarations, never assumed** — the card's control in the other
direction: a fix that makes absence visible by making the key mean
nothing would be worse than the defect. The two refusal cells are
unmoved: a string `'false'` and `null` are still refused by name.

### PR objectstack-ai#19130's consistency pin — flipped with its trigger registered, ⛔
not patched green

`packages/spec/src/api/package-install-one-authority.test.ts` asserted
`true` on every 缺省 reading. Only the 缺省 cell moves; the `false`, `true`,
string and `null` cells are untouched, and the authority/copy agreement
is still judged cell by cell.

The **flip-trigger phrase registered in the test** is:

```
缺省 = 保持,有旗 = 设置
```

It is a named `FLIP_TRIGGER` const with its own docblock explaining that
while the declarations spelled `.default(true)` the 缺省 reading was
living on borrowed time — the phrase says absence is a state the door
ACTS ON, and a `.default()` resolves absence at parse time so that state
cannot survive to the published surface. It is quoted into the 缺省 cell's
name so a test run prints it, and into the two flipped assertion titles.
The file's header docblock carries a section stating that the cell
FLIPPED, that this was expected on the day the pin landed, and that
reading the red as "the pin needs updating" and writing the new value in
silently is the failure the const exists to prevent.

## ⚠️ DECLARED file-surface expansion, with the mechanism that forces it

Beyond the three declarations, their tests and the changeset, four more
paths are in this diff. Each is mechanically forced; none is a
discretionary edit.

1. **`packages/spec/scripts/lib/default-changes.ts`** (+101).
`check:authorable-surface` **refuses the build** on an undeclared move
of an authorable key's default, and prints the copy-pasteable block
naming each key and both fingerprints. The build exits 1 until the
entries exist. Four entries are required, not three:
`InstallPackageRequestSchema` is re-exported through
`src/api/protocol.zod.ts`, so one declaration publishes under **two**
def keys (`kernel/InstallPackageRequest` and
`api/InstallPackageRequest`, byte-identical but for the `$id`) — the
`CreateImportJobRequest` / `ImportRequest` shape already in that table.
The ratchet names keys, not schemas, so dropping either row leaves that
def unauthorised and the gate red.
2. **`packages/spec/authorable-defaults/{api,kernel,marketplace}.json`**
(-4 lines total). Generated. `pnpm --filter @objectstack/spec build`
writes them; exactly the four `… = true` entries are removed and nothing
else moves.
3.
**`content/docs/references/{api/package-api,api/protocol,kernel/package-registry,marketplace/marketplace}.mdx`**
(+5 / -5). Generated by `gen:docs`, run via `check:generated --fix`,
which regenerated **only** the one artefact it proved stale. The four
projected rows lose their `(default: true)` cell and gain the
three-state prose. No other row moves.

`authorable-surface/*.json` and `authorable-surface.base.json` are
**not** in this diff: the keys stay authorable, and the base anchor is
only ever written by the explicit `gen:authorable-surface-base`, never
by a build.

## Verification

Reconciliation line, verbatim, derived and run at head `f5b094a96`:

```
Run reconciliation — 108 derived, 108 run, 0 NOT-MEASURED, 0 UNRUN.
```

`✓ dispatch-gates --ran: 108 derived famil(ies) accounted for — 108 run,
0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none
of them is 3).` Every command's exit code was captured **before any
pipe**; no command answered `exit 3`, so nothing in the derived set
measured nothing.

Everything below ran in the foreground; each heavy run went through
`scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-19273`,
and each verdict is that wrapper's own `VERDICT command-exit` line,
never a bare shell status.

| run | verdict |
| :--- | :--- |
| `pnpm --filter @objectstack/spec test` | `VERDICT command-exit 0` —
512 files, 14955 passed, 1 todo |
| `pnpm --filter @objectstack/rest test` | `VERDICT command-exit 0` —
194 files, 3265 passed, 1 skipped |
| `pnpm --filter @objectstack/runtime test` | `VERDICT command-exit 0` —
272 files, 3799 passed, 1 skipped |
| `pnpm exec turbo run typecheck` | `VERDICT command-exit 0` — 143 tasks
successful |
| `pnpm build` | `VERDICT command-exit 0` — 73 tasks successful |
| `pnpm --filter @objectstack/spec check:generated` | `VERDICT
command-exit 0` — all 15 generated artifacts up to date |
| `pnpm lint` | **exit 0**, run WHOLE (`eslint . --no-inline-config`),
not narrowed — so no narrowing evidence is owed |

`origin/main` was merged and the build state refreshed before the final
push; the generated re-check and the union above were both taken
**after** that merge, on the head this PR carries.

## ⚠️ The open reading the ruling hands the dev, reported as a zero WITH
its radius

**Zero consumers found that parse an install request through the
published schema.** The instrument's reachable radius, stated because a
zero without one is not a reading:

- **Reached:** `objectstack-ai/objectstack` at `f5b094a96` —
`packages/**`, `apps/**`, `examples/**`, `scripts/**`, `content/**`,
`docs/**`, `skills/**`, excluding `node_modules`. And
`objectstack-ai/objectui` at `0cf2d66`, the only sibling checkout in
this container, excluding `node_modules`.
- **objectui reading, with a positive control:** `enableOnInstall` —
**0** hits. `PackageInstall` (the schema name) — **0** hits. Control
that proves the instrument reads that tree: `packages.install` /
`/api/v1/packages` — **52** hits. So objectui calls the install route
and never names the key, never parses through the published schema.
- **⛔ NOT reached, and so NOT established in either direction:**
`objectstack-ai/cloud` (no checkout exists in this container) and any
third-party consumer of the published `@objectstack/spec`. The changeset
body and all four `DEFAULT_CHANGES_BY_MAJOR` reasons are written for
exactly that unreachable consumer — the caller who validates before
sending — because they are the only channel that reaches them.

## Changeset grade

**`minor`** for `@objectstack/spec`, ⛔ not the `patch` ruling objectstack-ai#157 item
5 wrote. Ruling objectstack-ai#210 item 4 overrode it and the override is measured:
`check-changeset-no-major.mjs`'s `judgeLevel` verdict `enforce` refuses
a clause-②-carrying diff whose moved packages are graded `patch` with
none at `minor` or above. Judged against `packages/spec/package.json`'s
`files[]` after a build as usual — `dist/` and `json-schema/` both ship,
and both move here — so the floor and the measurement agree. `node
scripts/check-changeset-no-major.mjs --base origin/main` and `node
scripts/check-adr-0087-registration.mjs --base origin/main` both exit 0
on this head.

## ⛔ Fences honoured

- **Not the engine half.**
`packages/runtime/src/domains/packages.ts:1095` verified to still read
`const requestedEnabled = wrapped ? body?.enableOnInstall : undefined;`.
The runtime is not in this diff.
- **The door does not sniff the raw body around the schema.** Nothing in
this PR adds a parse on the serving path.
- **No label writes of any kind**, and **no new issues filed** —
findings go back to the dispatching seat.

## Acceptance notes

None. Nothing outside this card's scope was surfaced that meets the
filing bar.

## 维护者速读(草稿)

**改了什么** — 三处 `enableOnInstall` 声明从「默认
true」改成「可缺省」。安装接口的实际行为半年前就被裁决改成了「不写这个键 =
保持这个包当前的启用/停用状态」,但对外发布的协议声明一直还写着「不写 = 启用」。这次让声明跟上已经生效的行为。

**为什么改** — 声明与实际不一致,受伤的是仓库外面的调用方。一个会先按协议校验请求再发送的客户端,会从「默认 true」里自动补出一个
`enableOnInstall: true` 发过来;而这个显式的 true
的含义是「强制启用」。结果就是:同样一个请求体,先校验的那一方会在每次升级时把运维手动停用的包悄悄重新打开,不校验的那一方则正常保持停用。两边行为相反,差别只在于有没有先校验。

**风险与代价(含回滚)** — 本仓内运行时行为零变化:安装接口读的是原始请求体,没有任何服务路径经过这几个 schema
解析,接受集也一个字节没动(缺省、true、false 照收,字符串和 null 照拒)。真正受影响的是仓外那位会校验的调用方,处方已写进
changeset 和四条默认值台账记录里:想要每次都强制启用,就把 `enableOnInstall: true`
显式写出来。回滚代价低——三处声明改回 `.default(true)`、撤掉四条台账记录、重跑生成即可,但回滚会把「声明 ≠
实际」这个问题原样退回去。

**席位意见** —

**你要做的** — 确认一件事就够了:仓外(尤其 cloud 侧和第三方)有没有会先按发布的 schema
校验安装请求、再把校验后的对象发出去的调用方。本次探测半径只到本仓和 objectui 两棵树,读数为零且带正控(objectui
会调安装接口但从不提这个键);cloud
在本容器里没有检出,所以那边是**未测**,不是「没有」。若那边确实有这样的调用方,它就是这次改动唯一会碰到的对象,而 changeset
里的处方正是写给它的。

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

---------

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

Fixes objectstack-ai#19735

## What this changes

Four statements in `.changeset/18605-enable-on-install-one-authority.md`
— a **pending, unreleased** fragment whose prose `changeset version`
publishes verbatim into `packages/spec/CHANGELOG.md`. One file, three
lines, `+3 / -3`. No other fragment and no other file in the tree is
touched, and the fragment's own `"@objectstack/spec": minor` header is
untouched.

The card named two of the four. The other two were found by the
re-derivation the card and the claim both demanded, and they are the
same defect in the same paragraph — a statement about the published
surface that the published surface no longer supports. Each of the four
is quoted old and new below, and the two extras are kept in their own
subsection so that what is being confirmed here is unambiguous.

## DELIBERATE CORRECTION — this is the written confirmation
`pr-automation.yml` route 0 requires, and `Check Changeset` is RED on
purpose

This PR **adds no changeset of its own**; it **changes a pending
changeset it did not add**. Route 0's discriminator, run against this
PR's merge base:

```
$ git diff --name-status 16d090e HEAD -- '.changeset/*.md'
M	.changeset/18605-enable-on-install-one-authority.md
```

Every row is `M`, none is `A` ⇒ route 0. The class is **DELIBERATE
CORRECTION, not COLLISION**: this PR did not draw that filename, nothing
of its own was overwritten, and the base copy must **not** be restored —
restoring it republishes the false sentences.
`check-empty-changeset.mjs` reaches the same reading on its own and
prints it in the job log unprompted.

| Route 0 prescribes | Here |
|---|---|
| ⛔ do **not** apply `skip-changeset` | Not applied, and it must not be:
the note corrected below is a release that is still pending, so the
label would be a false declaration. Ruling D on objectstack-ai#18375 forbids it
outright for a PR that edits an existing changeset. |
| Write the confirmation on the PR, naming the note and what changed
under it | This section and the two that follow it. |
| Leave `Check Changeset` **RED** | It is red, deliberately. It is not
one of the seven required contexts, so its red blocks no merge. The red
is what puts this decision in front of a person. ⛔ Please do not turn it
green, and please do not read it as a failure — every *other* check
should be green. |

### The note

`.changeset/18605-enable-on-install-one-authority.md` —
`"@objectstack/spec": minor`, **pending**, added by commit `596090efbe`
(objectstack-ai#19130) at 2026-09-20 10:43 UTC. `changeset version` deletes the
fragment and publishes its text verbatim into
`packages/spec/CHANGELOG.md`.

**The window is measured, not hypothetical.** `chore: version packages`
(**PR objectstack-ai#17076**) is open right now and its file list carries `removed
.changeset/18605-enable-on-install-one-authority.md`. Whichever of the
two lands first decides whether the false sentences ship.

## What changed under it — old and new, verbatim

### The two the card named

**(1)** old — the kernel copy's read behaviour:

> Its published description now records that this layer does not read
it: the implementation reads `manifest` and `settings` only,

**(1)** new:

> Its published description now records what this layer does with it:
the implementation honours the key on the registry row (`true` enables,
`false` disables, an ABSENT key makes no lifecycle call at all, tested
`=== true` / `=== false` so absence is never collapsed into either),

The remainder of that sentence — "and the HTTP door does not forward the
key down that seam — it calls `installPackage({ manifest, settings })`
and performs the enable/disable flip itself, because the durable half
must follow the row that door returned rather than the request's intent"
— is **still true at `origin/main`** and is left byte-for-byte as
written.

**(2)** old:

> Same type, same default, same meaning,

**(2)** new:

> Same type, same optionality, same meaning,

### Two more, found by the re-derivation the claim demanded — ⚠️ not in
the card

These are the same class as (1) and (2): a statement about the published
surface that the published surface no longer supports, in the same
fragment, in the same release window, mechanically correctable to a form
already pinned in the tree, on a file no other open PR modifies. They
are called out separately so the confirmation above covers four
corrections knowingly rather than two plus two silent ones. ⛔ If the
seat prefers the card's exact two, (3) and (4) are a one-commit revert —
say so and they come out.

**(3)** old — the install door's rule:

> `POST /api/v1/packages` writes the registry row's `enabled` from
`enableOnInstall ?? true` (objectstack-ai#18058)

**(3)** new:

> `POST /api/v1/packages` moves the registry row through the same verbs
`PATCH /packages/:id/enable` and `PATCH /packages/:id/disable` use:
`true` enables, `false` disables, and an ABSENT key makes no lifecycle
call at all, so the row the registry returned stands (objectstack-ai#18058)

⭐ This one is the most consequential of the four, because it publishes
**a rule the maintainer re-ruled against**. `?? true` says an absent key
means enable; the live contract is 「缺省 = 保持,有旗 = 设置」 (maintainer ruling
batch objectstack-ai#157 item 5 letter C). The tree already records that this exact
spelling is retired, in as many words —
`packages/objectql/src/protocol-install-package-enable-on-install.test.ts`
header: «The card that filed this work describes the target as
「`enableOnInstall ?? true` on install AND on re-install」. That sentence
was written before objectstack-ai#19291 landed and it is SPENT: `?? true` on
re-install is precisely what the HTTP door stopped doing.» Publishing it
into a CHANGELOG would hand an upgrading reader the reading the repo
removed from its own declarations.

**(4)** old — a verbatim quotation of the authority's published
description:

> Its published description now says so: "honoured at POST
/api/v1/packages: the installed row's `enabled` is written from this
key".

**(4)** new:

> Its published description now says so, naming the door that honours
the key and the three states it honours.

The *claim* ("its published description now says so") is true; the
*quotation* is not — that tail no longer exists in the published string.
A verbatim quotation of a mutable published description is exactly the
shape that falls out of date, so the replacement names the mechanism
instead of quoting the string.

## Why these four are defects in the record and not dated readings

Every instrument below was read on **`origin/main` at `16d090ede0`**, at
2026-09-22T20:12Z–20:26Z. The card's own citations were treated as input
and re-derived at source, ⛔ never quoted.

| The statement's claim | Instrument at `16d090ede0` | Reading |
|---|---|---|
| (1) "this layer does not read it" |
`packages/metadata-protocol/src/protocol.ts:22785` | `const
requestedEnabled = request.enableOnInstall;` — the layer reads it. |
| (1) same | same file `:22786`–`:22792` | `if (requestedEnabled ===
true)` ⇒ `registry.enablePackage(manifest.id)`; `else if
(requestedEnabled === false)` ⇒ `registry.disablePackage(manifest.id)`;
no `else` ⇒ an absent key makes no lifecycle call. Never truthiness,
never `??`. |
| (1) "its published description records" that |
`packages/spec/src/kernel/package-registry.zod.ts:357` | The description
now reads "…this protocol primitive honours it on the registry row:
`true` enables, `false` disables, and ABSENT keeps the row's current
lifecycle state…" — the description states the opposite of the
fragment's report of it. |
| (1) tail: door does not forward, flips itself |
`packages/runtime/src/domains/packages.ts:1045`, `:1095`–`:1101`,
`:1145` | `protocolSvc.installPackage({ manifest, settings:
body.settings })` — no `enableOnInstall` in the call; then the door's
own `=== true` / `=== false` arms; then `setPackageDisabled(...)` for
the durable half. **Still true ⇒ left as written.** |
| (2) "same default" | `packages/spec/src/api/package-api.zod.ts:432`
and `packages/spec/src/kernel/package-registry.zod.ts:356` | Both are
`z.boolean().optional()`. Neither carries a default, so there is no
default to be "the same". What *is* the same, and is what the parity pin
holds, is the type, the optionality and the meaning. |
| (3) "`enableOnInstall ?? true`" |
`packages/runtime/src/domains/packages.ts:1095`–`:1101` | Three-state
arms, and the comment above them states the rule verbatim: "⚠️ `===
true` / `=== false`, never a truthiness test and never a `??` default".
|
| (3) same, at the fragment's own seeding commit | `git show
596090e:packages/runtime/src/domains/packages.ts`, `:819`–`:823` |
Already three-state **when the fragment was written**. `git log --all -S
"enableOnInstall ?? true"` finds the string in no source file in the
repo's history — only in prose. ⇒ (3) was false when written, not
overtaken. |
| (4) the quoted description tail |
`packages/spec/src/api/package-api.zod.ts:433` | The published string is
now "…honoured at POST /api/v1/packages: `true` enables the installed
row, `false` disables it, and ABSENT keeps the row's current lifecycle
state (a fresh install lands enabled)". The quoted tail "the installed
row's `enabled` is written from this key" is gone. |

**When each became false** — (1) and (2) were true when written and were
overtaken within the week; (3) was false when written; (4) was
overtaken. Either way the entry is release-notes input that has not
shipped yet, so it is amended where it stands: AGENTS.md's
release-artifact row rules that a factual error in a release-bound entry
is amended in that entry, ⛔ never by an erratum in a later entry and ⛔
never by a rider on code changes.

| Statement | True when written at `596090efbe`? | Falsified by |
|---|---|---|
| (1) | yes — the description then read "…this protocol primitive does
not read it" | `482d584121` (objectstack-ai#19338, the primitive starts honouring it)
and `7e1b048a1d` (objectstack-ai#19691, the description is rewritten) |
| (2) | yes — both were `z.boolean().default(true)` | `fb59fb5e37`
(objectstack-ai#19690, both become `optional()`) |
| (3) | **no** — the door was already three-state at that commit | n/a;
false at seeding time |
| (4) | yes — the description then carried that exact tail | the same
rewrite that moved the api description to the three-state form |

All four replacements are **date-neutral**: they name the mechanism (the
three states and the verbs that apply them; the type/optionality/meaning
the parity pin holds) rather than a count, an enumeration or a quoted
string, so they stay true at `origin/main` and at publication alike.
"Same type, same optionality, same meaning" is in particular the
property `api/package-install-one-authority.test.ts` mechanically holds
— it parses both declarations over one matrix (absent, `false`, `true`,
a string, `null`) and reds on any cell where they disagree — so the
corrected sentence is kept true by a gate rather than by luck, which
"same default" never could be.

## What deliberately did NOT change

Every other byte of the fragment stays as written, and these in
particular were re-derived and **deliberately left**:

- Past tense, describing the **pre-objectstack-ai#18605 state**, and true of it:

> What was left was three declarations that looked identical
(`z.boolean().default(true)`, same description)

Verified: both declarations really were `z.boolean().default(true)` at
`596090efbe`. A dated record's job is to say what was true when it was
made, so overwriting it would falsify history rather than correct a
record.
- A **scope statement about what objectstack-ai#18605's own change did**, not a claim
about today's declarations:

> No key is added, removed, renamed or retyped, and no default changes:
the accept set of all three schemas is byte-for-byte what it was, and
`api-surface`, `authorable-surface` and `authorable-defaults` are all
unchanged.

True of that change then, and still true of it now. The later removal of
the defaults was a different change (objectstack-ai#19273, objectstack-ai#19690) carrying its own
notes.
- The whole **parity-pin paragraph** — re-derived and it stands:
`api/package-install-one-authority.test.ts` exists and pins the
five-cell matrix named there, and the `OS_EAGER_SCHEMAS=1` cycle it
describes is restated verbatim in `kernel/package-registry.zod.ts`'s own
doc block.
- The whole **marketplace paragraph** — re-derived and it stands:
`MarketplaceInstallRequestSchema`'s subject fields are `listingId`
(`marketplace.zod.ts:481`), `version`, `licenseKey` (`:487`) and
`tenantId` (`:545`), and its own description names itself "the
marketplace channel's own install option, not the platform install-door
key".
- The opening line's "declared in three published schemas" — re-derived:
`grep -rn "enableOnInstall: z" packages/spec/src/` returns exactly three
declarations, and each names the authority in its own description.
- The `"@objectstack/spec": minor` header and the `Clause-②: yes` line.

## Verification

Gate families derived from **this worktree**, ⛔ never from the shared
checkout:

```
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands
```

It derived **19** families at commit `fbb8952c3e`, and confirmed the
`--repo` assertion against this checkout's `origin`. All 19 were run,
each exit code captured **before any pipe**, recorded as `command ::
exit code`, and reconciled:

```
✓ dispatch-gates --ran: 19 derived famil(ies) accounted for — 19 run,
  0 NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and none of them is 3).
```

**18 of 19 exit 0.** The one non-zero is the expected one:

- `node scripts/check-empty-changeset.mjs --base origin/main` — **exit
1, the route-0 red**. Its output names this PR's class as DELIBERATE
CORRECTION on its own and ends: "this gate stays red either way, and
staying red is what puts the decision in front of a person instead of
routing around it."

Run in addition, because `dispatch-gates` flagged that this family's
roster lives under `.changeset`, which is where this PR's only path is:

- `node scripts/check-changeset-fixed.mjs` — exit 0,
"`.changeset/config.json` "fixed" group is in sync with 70 public
workspace packages" (a verdict over a real population, not a vacuous
green).

**Repo-wide `pnpm lint` narrowed to this diff, and the narrowing proven
rather than asserted** — all three readings, so the narrowing is a
measurement and not a skip:

1. **Population, read from eslint's own predicate**, not guessed. On one
`ESLint({ cwd })` instance:
`isPathIgnored('.changeset/18605-enable-on-install-one-authority.md')`
is `true`, and the positive control
`isPathIgnored('scripts/check-nul-bytes.mjs')` is `false` — so the
predicate is shown able to answer either way. Every `files` glob in
`eslint.config.mjs` names TS/JS extensions only
(`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and four narrower TS-only
globs), and a case-insensitive count of `markdown` or the markdown
extension in that config is **0**.
2. **File count, read from the `--format json` shape**: `lintFiles` over
both paths returns 2 entries; the changed path's entry is `errorCount 0,
warningCount 1` whose only message is "File ignored because no matching
configuration was supplied" — zero rules evaluated — while the control
path's entry is a genuinely linted `errorCount 0, warningCount 0` with
no ignore message.
3. **Invariance for untouched files**: the one changed path is in no
eslint population at all and no markdown processor is configured, so
this diff hands nothing to a parser and cannot move any untouched file's
verdict. Type-aware linting does not enter into it — the file is never
parsed.

**No package build, test or typecheck is owed**: the diff touches one
`.changeset/*.md` file and no package source, so there is no
affected-package closure to build and no package's public surface moves.
`dispatch-gates` independently reports the change set as 1 path, `+3 /
-3`, 6 changed lines.

**Control characters** — `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'`
over the changed file exits 1 (no hits), with the same pattern exiting 0
on a seeded BEL control file in the same run, and a near-miss class
(`[\x09]`) exiting 1 on that same control file so the class is doing the
discriminating. `pnpm check:nul-bytes` exits 0.

**Every zero above carries its controls.** On the `grep -c -F`
instrument over the changed file, at `fbb8952c3e` / 2026-09-22T20:26Z:
firing controls `same optionality` = 1 and `honours the key on the
registry row` = 1 (the subject is alive on this instrument);
measurements `enableOnInstall ?? true` = 0, `Same type, same default` =
0, `this layer does not read it` = 0, `is written from this key` = 0;
dark controls `same optionalities` = 0 and `enableOnInstall ?? false` =
0.

## Acceptance notes

Observations found while verifying, deliberately **not** acted on and
**not** filed:

- **The dispatch and the claim both describe the fragment's header as
`"@objectstack/spec": patch`; at source it is `minor`.** Re-derived at
`16d090ede0`: line 2 is `"@objectstack/spec": minor`. The header is not
this PR's to change either way, `check-changeset-no-major` exits 0 on
it, and the discrepancy is an input-vs-source one rather than a defect
in the tree. Recorded only so a re-measurer does not read it as drift.
- **The card's line numbers drift against `origin/main`, substance
identical.** The card cites `protocol.ts:22773–22779`; the arms are at
`:22785`–`:22792`. It cites `package-api.zod.ts:434`; the declaration is
at `:432`. `package-registry.zod.ts:356` is exact. Noted only so a
re-measurer does not read the drift as disagreement — this is precisely
why the claim demanded re-derivation.
- **The retired `?? true` spelling appears nowhere else in the repo's
release-bound prose.** `git grep -n "enableOnInstall ?? true"` at
`16d090ede0` returns exactly two carriers besides this fragment, both of
which are *about* the spelling being retired rather than asserting it:
`packages/objectql/src/protocol-install-package-enable-on-install.test.ts:23`
and `:188`. No other `.changeset/*.md` carries it. Carrier: none needed.

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] enableOnInstall is declared in three schemas and honoured by no handler — an author sets it and the runtime silently ignores it

2 participants