fix(spec): enableOnInstall becomes optional() so absence survives the parse - #19690
Conversation
The published declarations claimed `.default(true)` — "absent means enabled" — while the install door has honoured 「缺省 = 保持,有旗 = 设置」 since the runtime half landed: absent makes no lifecycle call, so a package an operator disabled stays disabled across a re-install. A `.default()` resolves absence at parse time, which erases the third state from the published surface and leaves a declared-but-unenforced default on a contract this repo does not own both ends of. `optional()` keeps the state visible; the `true` and `false` arms are unchanged. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
The #18605 consistency pin asserted `true` on every 缺省 (absent) reading, which was the behaviour this change moves. The cell is flipped with its registered flip-trigger phrase rather than patched green, so the next reader meets the reason instead of a silently edited expectation. Only the 缺省 cell moves. The `true` and `false` arms are re-read after the change on all three declarations — a fix that makes absence visible by making the key mean nothing would be worse than the defect. `check:authorable-surface` refuses an undeclared default move, so the four published def keys are declared in DEFAULT_CHANGES_BY_MAJOR. Four keys from three declarations: `InstallPackageRequestSchema` is re-exported through `src/api/protocol.zod.ts` and publishes under both `kernel/` and `api/`. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
`gen:docs` reprojects the four published defs whose `describe` moved: the `(default: true)` cell is gone and the prose now states all three states. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
…ableoninstall-optional
…ableoninstall-optional
📓 Docs Drift CheckThis PR changes 1 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 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 107281972e65586c76e512e72c205c16f3153436 && git checkout 107281972e65586c76e512e72c205c16f3153436
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1d41aa88553754276eb350c1a068a817d3560e2d 6584b9dcd72706e4ca9af7291424297fbc50461a && git checkout -B drift-repro 1d41aa88553754276eb350c1a068a817d3560e2d && git merge --no-ff 6584b9dcd72706e4ca9af7291424297fbc50461a
node scripts/docs-audit/affected-docs.mjs --json 1d41aa88553754276eb350c1a068a817d3560e2d
|
The header block claimed this file "registered in advance" the phrase the 缺省 cell would later flip on. Measured on the parent commit: the file carried no such phrase and no flip or trigger note of any kind — zero hits for 缺省 / 保持 / 有旗 / flip / trigger, with `MATRIX` and `absent` as the controls proving the file was read. The claim was false about this repository, and it contradicted the const's own docblock twenty lines below, which said correctly that the phrase is registered here. The const's version is the true one and is kept. The header now states that the trigger is registered WITH the flip rather than ahead of it, and records the gap as the lesson: a pin written against a contested cell needs its trigger when the pin lands, while the contest is known, not when the cell finally moves. Comment-only. No assertion, matrix cell, changeset or declaration moves. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract review
Reviewed-by: ⛔ Reviewed against the diff and Which limb of clause ② this is — and which it is NOT⛔ The accept set does not move, in either direction. ✅ What moves is the published PARSE OUTPUT and the published surface — two of the other three forms at once:
⭐ The consumer this actually protects is the sharpest thing in the diff, and it survives my own re-reading: a client that VALIDATES its request through the published schema materialised What this seat verified by reading the diff, ⛔ not the report
One item was sent back before this record, ⛔ not waved throughThe pin's new header claimed Declared, so it is not discovered later
VerdictACCEPT. CI on this head: 33 success · 2 skipped · 0 incomplete, latest-per-name, Generated by Claude Code |
Carrier stripped on PASS, and the record it cites. 2026-09-22T09:10Z
The record: the in-seat contract review at ⛔ Why this PR was ejected at 08:48:24Z — the fault is this seat's, stated rather than left as a mystery
⇒ this seat enqueued with the carrier still hung. The protocol's step 3 (strip on PASS, cite, re-enqueue) was written into this seat's own notes as a POST-landing step; it is a PRE-enqueue one. Corrected here, and corrected for the sibling PR #19685, which was ejected by the identical leg at 09:00:35Z for the identical reason. Generated by Claude Code |
⛔⛔ WITHDRAWN AS THE REVIEW OF RECORD — this seat is not at
|
Docs drift — receipted by the seat, with the reading that answers the run's own gap. 2026-09-22T09:29Z
The gap the run declared, answered rather than inherited
Those three files are the GENERATED authorable-default ledgers, and what documents them is the served JSON Schema rendered into The question that actually matters, and its measurementThis PR removes a published DEFAULT. The page it could falsify is one that states 「absent means enabled」. Measured over
⇒ it shows the key optional and written explicitly as The other nine pagesEvery one is matched through a ROUTE literal (
Generated by Claude Code |
Contract review
Served-tier: Reading time: 2026-09-22T09:33Z — checks, mergeability and carriers read at this instant. Rendered by the isolated at-tier review subagent spawned by the ① Derived judgmentsLimb. ⛔ Not 收紧, ⛔ not 放宽. On all three declarations the only change is
So the seat's two readings hold, tested independently: the accept set does not move, and the clause is hit by the published parse output plus the Consumer observability. In-repo: none — no serving path parses through the schema, so no deployed behaviour moves. Out-of-repo: only a caller that validates through the published schema and sends the PARSED object; for that caller absence used to materialise as an explicit Pin discipline. PR #19130's consistency pin FLIPPED with Direction. Ruled, not chosen: batch #210 item 4 letter A (
Non-blocking, declared so it is not discovered later. Two comment sentences elsewhere go stale once this lands — ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS — on head Generated by Claude Code |
…ableoninstall-optional # Conflicts: # packages/spec/src/kernel/package-registry.zod.ts
Discharges the merge commit's os-regen deferral. The three pages both sides moved were restored to main's side by the regen-merge script's step 2 — the driver had merged them with exit 0 while silently dropping one side — and are re-derived here from a dist built on the merged source, never text-merged. Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1 Co-authored-by: Claude <noreply@anthropic.com>
Contract review
Served-tier: CONTRACT_REVIEW_TIER — the constant's NAME; verified first on Reading time: 2026-09-22T10:40Z — checks, mergeability and carriers read at this instant. Rendered by the isolated at-tier review subagent spawned by the ⭐ This record SUPERSEDES ① Derived judgmentsWhat moved between the two heads, measured. Condition ① — Condition ② — the describe. The merged Condition ③ — the docblock. Both sections stand: Condition ④ — regenerated, ⛔ never text-merged. This is the one only the generator can answer, so it was answered by the generator, four ways.
Condition ⑤ — the one-authority matrix re-run. Limb, re-affirmed on this head. ⛔ Not 收紧, ⛔ not 放宽: ② Semver level
③ Boundary flags
Joint with Carried from Still standing, outside this PR: CI on this head: 35 check runs — 33 Implemented-by: VERDICT: PASS — on head Generated by Claude Code |
Carrier stripped on the at-tier PASS, and the record it cites. Enqueueing. 2026-09-22T10:41Z
The record: the at-tier contract review at Read back by this seat before stripping: heading present · head sha alone in a code span · One question this seat handed the reviewer rather than deciding — and it was right not to decide it
Ruled: no — and ⛔ do not file it either. The reviewer found what this seat had not: card #19339 ( Generated by Claude Code |
…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>
Fixes #19273
Clause-②: yes
Ruling batch #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:1095has honoured 「缺省 = 保持,有旗 = 设置」 since PR #19291 landed:truecallsenablePackage,falsecallsdisablePackage, 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 settrue. 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 thedescribeand the docblock — absent = keep the row's current lifecycle state; explicittrue/falseunchanged; a fresh install lands enabled:api/PackageInstallRequest— the authoritypackages/spec/src/api/package-api.zod.tskernel/InstallPackageRequest— the copypackages/spec/src/kernel/package-registry.zod.tsmarketplace/MarketplaceInstallRequest— a different party's keypackages/spec/src/marketplace/marketplace.zod.tsThe executable criterion, both directions
Read off the built package (
packages/spec/dist), notsrc/, at headf5b094a96:The
trueandfalsearms 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'andnullare still refused by name.PR #19130's consistency pin — flipped with its trigger registered, ⛔ not patched green
packages/spec/src/api/package-install-one-authority.test.tsassertedtrueon every 缺省 reading. Only the 缺省 cell moves; thefalse,true, string andnullcells 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_TRIGGERconst 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.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.
packages/spec/scripts/lib/default-changes.ts(+101).check:authorable-surfacerefuses 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:InstallPackageRequestSchemais re-exported throughsrc/api/protocol.zod.ts, so one declaration publishes under two def keys (kernel/InstallPackageRequestandapi/InstallPackageRequest, byte-identical but for the$id) — theCreateImportJobRequest/ImportRequestshape already in that table. The ratchet names keys, not schemas, so dropping either row leaves that def unauthorised and the gate red.packages/spec/authorable-defaults/{api,kernel,marketplace}.json(-4 lines total). Generated.pnpm --filter @objectstack/spec buildwrites them; exactly the four… = trueentries are removed and nothing else moves.content/docs/references/{api/package-api,api/protocol,kernel/package-registry,marketplace/marketplace}.mdx(+5 / -5). Generated bygen:docs, run viacheck: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/*.jsonandauthorable-surface.base.jsonare not in this diff: the keys stay authorable, and the base anchor is only ever written by the explicitgen:authorable-surface-base, never by a build.Verification
Reconciliation line, verbatim, derived and run at head
f5b094a96:✓ 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 answeredexit 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.shwithOS_VERIFY_LOCK_SLOT=issue-19273, and each verdict is that wrapper's ownVERDICT command-exitline, never a bare shell status.pnpm --filter @objectstack/spec testVERDICT command-exit 0— 512 files, 14955 passed, 1 todopnpm --filter @objectstack/rest testVERDICT command-exit 0— 194 files, 3265 passed, 1 skippedpnpm --filter @objectstack/runtime testVERDICT command-exit 0— 272 files, 3799 passed, 1 skippedpnpm exec turbo run typecheckVERDICT command-exit 0— 143 tasks successfulpnpm buildVERDICT command-exit 0— 73 tasks successfulpnpm --filter @objectstack/spec check:generatedVERDICT command-exit 0— all 15 generated artifacts up to datepnpm linteslint . --no-inline-config), not narrowed — so no narrowing evidence is owedorigin/mainwas 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.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:
objectstack-ai/objectstackatf5b094a96—packages/**,apps/**,examples/**,scripts/**,content/**,docs/**,skills/**, excludingnode_modules. Andobjectstack-ai/objectuiat0cf2d66, the only sibling checkout in this container, excludingnode_modules.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.objectstack-ai/cloud(no checkout exists in this container) and any third-party consumer of the published@objectstack/spec. The changeset body and all fourDEFAULT_CHANGES_BY_MAJORreasons are written for exactly that unreachable consumer — the caller who validates before sending — because they are the only channel that reaches them.Changeset grade
minorfor@objectstack/spec, ⛔ not thepatchruling #157 item 5 wrote. Ruling #210 item 4 overrode it and the override is measured:check-changeset-no-major.mjs'sjudgeLevelverdictenforcerefuses a clause-②-carrying diff whose moved packages are gradedpatchwith none atminoror above. Judged againstpackages/spec/package.json'sfiles[]after a build as usual —dist/andjson-schema/both ship, and both move here — so the floor and the measurement agree.node scripts/check-changeset-no-major.mjs --base origin/mainandnode scripts/check-adr-0087-registration.mjs --base origin/mainboth exit 0 on this head.⛔ Fences honoured
packages/runtime/src/domains/packages.ts:1095verified to still readconst requestedEnabled = wrapped ? body?.enableOnInstall : undefined;. The runtime is not in this diff.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