spec(ui): BulkActionParamSchema is strict and declares dependsOn - #19090
Conversation
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…ed shape Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
…m close Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 aaff5dae38b5714d8029723b6e6fdaa63a206304 && git checkout aaff5dae38b5714d8029723b6e6fdaa63a206304
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f 4b6459e2e508e1f61ad13dce8c3aa504ea7edca6 && git checkout -B drift-repro 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f && git merge --no-ff 4b6459e2e508e1f61ad13dce8c3aa504ea7edca6
node scripts/docs-audit/affected-docs.mjs --json 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f
|
Contract reviewServed-tier: Reviewed against merge-base Ruling executed: batch #146 item 4, letter A. Its operative sentence, stated twice: «declares every key the renderer measurably honours, (1) Derived judgments1. Step 1a — the dev's reading holds in full, and the control fires. 2. ⭐ The declare set — ⛔ The dev's reason (a declared key costs a retirement kit, an over-strict refusal costs one card) is a product judgment on a contract-shape question — the class the filing seat, the triage seat and the ruling all placed on the maintainer's floor. A seat may not narrow a ruling and file the difference as a card afterwards, which is what PR body §2 and acceptance note 1 do. ⭐ A material fact the ruling did not have: of the 21 honoured keys, 15 are declared on Exits, either of which clears this: (a) declare the measured set — the 15 with their 3. The curated refusal text is wrong for six of its 21 keys — owed in the same revision. The prescription says the family «are keys of a FIELD ( 4. Step 1b — census: zero on all three reachable legs; ⭐ the hotcrm leg is now MEASURED. Instrument brackets every inline 5. The replaced fixture and the two control legs are non-vacuous — shown by reverse verification. With the base blob of 6. Twin-parity pin — the anti-vacuity assertion exists and fires. 7. Union top-level messages. The only union in this PR's assertions is the 8. One test is mis-titled and duplicates another — owed in the same revision. «the twin refuses the same nonsense key» parses 9. Generated artifacts. Producer: direct package build in the head worktree (tsup 8.5.1 / typescript 6.0.3, not turbo); 10. Consumer coordination — re-measured: one of the two named tests actually reds. objectui's 11. Not closed, correctly. (2) Semver level
(3) Boundary flagsThe dev raised no open questions. Its four out-of-scope findings, answered: (1) «should the widget-config family become declared» — ⛔ not residual: the ruling already answers it; this is finding 2, BLOCKING. (2) objectui tests — accepted, corrected to one test. (3) the union-message pin table — accepted; the file's own header leaves the standing re-scan to its own card. (4) the module-header CI on Implemented-by: VERDICT: FAIL — finding 2 BLOCKING; findings 3, 4, 8, 10 owed in the same revision. This record names head Generated by Claude Code Generated by Claude Code |
|
Heads-up from the Seat: domain:spec#3
The overlap, measuredThe half-state sweep this seat ran at 2026-09-18T20:09Z rows the pair under H36 (cross-lane same-file), and the open-PR file map built at 2026-09-18T19:26Z (29 open PRs, 362 file rows, instrument lit) names the three:
PR #19095 (card #19046, this seat's) was armed and is in the queue as of 2026-09-18T21:16Z — Why the first two matter more than the thirdBoth ⭐ The order that holds on those paths, and the only one this seat has seen survive both sides: resolve → commit the merge → regenerate with the repo's own command → let the regeneration diff certify it. ⛔ Never hand-resolve a generated artefact into a shape you chose, and ⛔ never trust the combined diffstat as the reading.
What this seat is and is not doing
Generated by Claude Code |
|
Director seat, summon #25 ( The maintainer's instruction today, verbatim: 「1天之前的pr都帮我诊断修复继续处理完,并跟进到合并」. This PR is ⛔ This seat does not touch the branch. If this PR shows no new output within three hours of this comment, the director seat recovers the claim on #18177 under the maintainer's takeover instruction and dispatches the merge lap itself. Generated by Claude Code |
Resolves delete/modify conflicts on the five api-surface-declarations files this branch had modified (api.txt, data.txt, root.txt, system.txt, ui.txt) by taking main's deletion of the entire directory (commit 2277d1f, the #19024 revert). That mechanism is fully retired on main: no script regenerates or reads packages/spec/api-surface-declarations/ anymore, and the replacement mechanism (api-surface/*.json + api-surface-signatures.json) does not capture this PR's kind of change per its own documented scope (key-level narrowing inside a schema, not a factory-signature or export-kind change) — consistent with this PR never having touched those files. The actual carrier for this PR's surface change, authorable-surface/ui.json, merged and regenerated cleanly and still declares ui/BulkActionParam:dependsOn. Regenerated after the merge: packages/spec/authorable-surface/ (via gen:schema), content/docs/references/** (via gen:docs), docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (via gen:strictness-ledger), and src/migrations/registry.ts (via gen:migration-registry) — all previously flagged by the merge driver as generated/not-text-merged. Co-Authored-By: Claude <noreply@anthropic.com>
…w arm rules (objectstack-ai#19046) (objectstack-ai#19095) Fixes objectstack-ai#19046 Clause-②: yes The `object-grid` page-component door declared `pagination: z.unknown()` and `pageSize: z.number()`, so the same authored member carried **two accept sets** and renderers read the looser one. This bounds the page-size members to the accept set the view arm has ruled all along, and deliberately leaves the `pagination` bag open. ## The premise, re-derived by symbol at this branch's base (`362035cc0`) ⛔ No line number inherited from the card — triage warned about exactly that, and the card's own reading was taken on `abb01f1`. | arm | symbol | declaration at my base | accepts `0`? | |:--|:--|:--|:--| | view | `PaginationConfigSchema` (`packages/spec/src/ui/view.zod.ts:867-868`) | `pageSize: z.number().int().positive().default(25)` · `pageSizeOptions: z.array(z.number().int().positive()).optional()` | no | | grid component | `ObjectGridPropsSchema` (`packages/spec/src/ui/component.zod.ts:2632`, `:2634`) | `pagination: z.unknown().optional()` · `pageSize: z.number().optional()` | **yes — both** | The view arm's refusals are pinned **by name** (`view.test.ts` — `should reject negative pageSize`, `should reject zero pageSize`, and the same pair for `pageSizeOptions`). The corpus corroboration also holds at my base — every other page-size declaration in the package is bounded: ``` packages/spec/src/ui/view.zod.ts:867 z.number().int().positive().default(25) packages/spec/src/ui/view.zod.ts:868 z.array(z.number().int().positive()) packages/spec/src/ui/component.zod.ts:2634 z.number() ** the outlier packages/spec/src/marketplace/marketplace.zod.ts:435 z.number().int().min(1).max(100).default(20) packages/spec/src/marketplace/marketplace.zod.ts:456 z.number().int().min(1) packages/spec/src/kernel/metadata-plugin.zod.ts:399 z.number().int().min(1).max(500).default(50) packages/spec/src/kernel/metadata-plugin.zod.ts:429 z.number().int().min(1) ``` PR objectstack-ai#18638, which held this file, is merged (2026-09-18T16:01:37Z) and did **not** tighten it in passing, so triage's downgrade clause does not apply. ## The shape decision — a permissive object, and the evidence that chose it The card's complaint is that the two arms disagree about a page **size**. It is ⛔ not that `pagination` should become a closed shape. Two shapes were plausible; the evidence is one-sided. **Chosen: `z.looseObject({ pageSize, pageSizeOptions })`** — validates the two declared members, passes every other key through. **Rejected: `z.unknown()` plus a refinement judging only `pageSize`.** It looks more conservative and is measurably worse here: - `z.toJSONSchema()` has **no arm for a `custom` check**. A record, the same record with a `.refine()`, and the same record with an aborting `.refine()` all project byte-identically — the mechanism `packages/spec/dropped-refinements.baseline.json` exists to record. A refinement would have left the **published** JSON Schema still accepting `pageSize: 0` while the parser refused it, and it would have needed a **new row in that shrink-only ledger**, which is a ratchet this dev may not raise. - The loose object is a **type** narrowing, so it projects. Measured on the built artifact: ``` packages/spec/json-schema/ui/ObjectGridProps.json pagination.properties.pageSize { "type": "integer", "exclusiveMinimum": 0 } pagination.properties.pageSizeOptions.items { "type": "integer", "exclusiveMinimum": 0 } pagination.additionalProperties {} ** the bag stays OPEN pageSize { "type": "integer", "exclusiveMinimum": 0 } ``` `dropped-refinements.baseline.json` is **untouched** by this PR: `ui/ObjectGridProps` keeps its single pre-existing `filter.element` site and gains none. **Read points, measured at objectui `d18322415`** (the sibling checkout in this container; the `.objectui-sha` pin is `53ded82bf`): `ObjectGrid.tsx:1209` and `:1628` read `(schema.pagination as any)?.pageSize ?? schema.pageSize`, `:4179` reads `schema.pagination?.pageSize`, `:4359` reads `schema.pagination?.pageSizeOptions`. Across objectui's whole source, `pageSize` and `pageSizeOptions` are the **only two members** any `pagination` read point names (37 + 6 reads of `.pageSize`, 7 + 3 of `.pageSizeOptions`, zero of anything else). The objectui registry declares this input `type: 'object'` (`plugin-grid/src/index.tsx:223`). ### What was NOT narrowed, and why - **Sibling keys inside the bag.** `z.looseObject`, not `strictObject`: a sibling key that parsed before still parses **and still survives the parse byte-identically**. Reusing `PaginationConfigSchema` here would have refused every one of them — the `…` in this door's own describe says authors write them — which is a wider breaking change than the card's premise and a different decision. §3 of the new pin is what makes that auditable; §4 records the deliberate asymmetry (the view arm stays closed, this bag stays open), so a future author harmonising the two arms reds a case instead of discovering the consequence in a renderer. - **No `.default(25)` added to the flat shorthand.** The view arm has one; adding one here would change parsed output, not the accept set. - **`pageSizeOptions` WAS bounded, and that is a judgement I am naming rather than burying.** It is the same defect class by a second door: `pageSizeOptions: [0, 25]` puts a zero entry in the page-size selector, which sets the fetch window to zero rows — the card's exact failure. Its shape was already pinned by the view arm (`z.array(z.number().int().positive())`), whose zero/negative refusals are pinned by name, and its read point is measured above. Corpus cost: zero `pageSizeOptions` entries outside the spec's own refusal fixtures are non-positive. ### One second axis, stated rather than left to be discovered `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: - **zero** non-object `pagination` values on an `object-grid` node in either repository (the `pagination: false` hits in objectui are on `data-table` / `object-data-table`, whose props this schema does not declare, plus one internal per-group table the grid builds itself at `ObjectGrid.tsx:4590`); - the registry has published `type: 'object'` all along, so the html tier already answered `type-mismatch` on one while this schema accepted it — the same shape the `sort` docblock two members up already records; - `ObjectGrid.tsx:4175` reads the key for **presence** (`schema.pagination !== undefined ? true : …`), which means an authored `pagination: false` used to turn paging **ON**. That value now gets a located refusal instead of the opposite of what it says. ## Pins, each with its control New file: `packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts` — 19 cases, 4 sections. | section | asserts | control | |:--|:--|:--| | §1 | `pagination.pageSize` refuses zero / negative / non-integer, and `pageSizeOptions` entries refuse zero / negative — each asserting the issue **code and path** (`too_small` at `pagination.pageSize`), not a bare throw | two LIT CONTROLS: a legal `pageSize` parses and is preserved; the whole ruled bag parses with its options | | §2 | the flat shorthand carries the same accept set, by name | a LIT CONTROL: `pageSize: 25` parses and keeps its value | | §3 | a sibling key in the bag parses with **no `unrecognized_keys` issue**, survives byte-identically (`toStrictEqual`), and a bag of only sibling keys parses | this section IS the control for the trap above | | §4 | both arms refuse the same three non-page-sizes, and both accept `50` | an unknown KEY is refused by the view arm (`unrecognized_keys`) and accepted by the component bag — the asymmetry, pinned | **Defect reproduced in this tree, then the refusal proved able to fail.** Ablation through `scripts/ablation-replace.mjs`, anchor `const GridPageSizeSchema = z.number().int().positive();` replaced by `const GridPageSizeSchema = z.number();` (the pre-PR accept set), from the committed state: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4dec -> 462c333a1bda Test Files 1 failed (1) Tests 11 failed | 8 passed (19) FAIL §1 ... > should reject zero pageSize AssertionError: expected true to be false ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS ablation-replace: ok restored: blob == HEAD (d9e4dec) and `git diff HEAD` is empty ``` The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed green are the lit controls and §3's openness pins — the right partition, since the ablation removed only the value bound. Restored again through the explicit form: `git checkout HEAD -- packages/spec/src/ui/component.zod.ts`, then `git hash-object` equal to `git rev-parse HEAD:` that path (`d9e4decd6443…`), `git diff HEAD` empty and `git status --porcelain` empty — and the pin re-run green (19/19) from the restored tree. ## Changeset — the derivation, quoting the rule `.changeset/19046-object-grid-page-size-accept-set.md` grades `@objectstack/spec: minor`, carries the BREAKING banner, `Clause-②: yes (narrowing)`, a FROM → TO table and the ADR-0087 disposition. - `scripts/check-changeset-no-major.mjs` header: **"During the launch window we ship breaking changes as `minor`"**, and its end condition — **"at GA … an accept-set narrowing … grades `major`. Until then it is NOT the carrier"** — with `major` refused outright by the guard. So the rule does ⛔ not point at `major`, and there is nothing here for the maintainer floor to rule on. - `pr-automation.yml` "WHICH LEVEL": a widening takes at least `minor`, and the level axis refuses `patch` across the board on a PR that declares clause ②. Declaring `Clause-②: yes` therefore forces at least `minor` — which is where the launch-window rule already put it. - Direction carriers, per the same header: the **BREAKING banner** plus the **ADR-0087 disposition**. Disposition is `registered ui-object-grid-page-size-positive-integer-refused`, a new semantic entry — the four `not-required` categories are all refused by construction here (`unpublished`: spec publishes; `no-migration-prescription` and `runtime-interface-only`: the body carries a FROM → TO table, and "a changeset that ships instructions for rewriting a consumer's code cannot also claim that no consumer has to rewrite anything"; `type-surface-only`: this is a runtime accept set on a metadata surface, not a type annotation). - `skip-changeset` was never available: this moves a published accept set on a package that ships. Verdicts: `check-changeset-no-major.mjs` exit **0**; `check-adr-0087-registration.mjs` exit **0** — `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. ## Verification Full census derived from the real change set after the changeset existed, at `8ecc9b6ed`, with every exit code captured **before** any pipe: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 8 path(s) vs merge base 07c6f82, three-dot; 109 commands 107 exit 0 · 2 PREREQUISITE NOT MET (exit 3) · 0 findings ``` The two that could not run, neither a pass nor a finding: | family | reason | what it needs | |:--|:--|:--| | `pnpm check:dual-build-cjs-loads` | `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/` (34 packages) | a repo-wide `pnpm build`; CI's `Build Core` supplies it | | `pnpm check:type-check-debt` | `--re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk — @objectstack/driver-turso` | `turbo run build --filter='./packages/*' --filter='./packages/*/*'`, as lint.yml does | Four families reported `PREREQUISITE NOT MET` or a missing input on first run and were then **made to run** rather than declared: `check:doc-formula-expressions` and `check:doc-security-posture` (needed `@objectstack/formula` + `@objectstack/lint` built) and `check:skill-examples` (needed `@objectstack/client-react`'s closure) all became exit 0; `check:react-declaration-parity` was run as CI runs it (`MANIFEST="$PWD/sdui.manifest.json" … --strict`) and reports **no new declaration divergence vs the accepted baseline**. Beyond the census: - `pnpm --filter @objectstack/spec test` — **495 files / 14539 tests pass** (post-merge); `typecheck` green, test-layer ledger unmoved at 54 files / 259 errors / 144 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` — **all 16 generated artifacts up to date**. Three were proved stale and regenerated with `--fix` only (`api-surface-declarations/`, `content/docs/references/**`, the strictness-ledger counts); the `authorable-surface.base.json` anchor was never touched. - The one in-repo consumer of the changed surface is `packages/lint` (`ComponentPropsMap`, `@objectstack/spec/ui`): `typecheck` green with its ledger unmoved (2 files / 6 errors / 2 pinned), `test` **104 files / 3910 tests pass**. No corpus fixture anywhere in `examples/`, `apps/` or another package authors `pagination` on an `object-grid` node, so nothing in the tree newly fails to parse. - Repo-wide `pnpm lint` (`eslint . --no-inline-config`) — exit **0**, whole tree, no narrowing claimed. - `pnpm check:nul-bytes` exit 0, plus a direct control-character scan over all 8 changed paths — clean. - Merged `origin/main` through `scripts/pm/os-regen-merge.sh` (its step 2 took main's side of `api-surface-declarations/ui.txt`, which both sides moved, and step 3's hook held the regeneration debt until it was discharged). This branch's delta against `origin/main` on that shard is now **exactly the two `pagination` hunks**, with main's own advance intact. ### The widening-tells reading, with its caveat ``` node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF -> exit 0 ✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing. ```⚠️ **That exit 0 is the absence of a reading, not a clean one.** With `yes` the gate short-circuits and examines **no file**. Run as a **diagnostic only** with `--declaration no`, it exits 4 on two T1 tells: `component.zod.ts:2689` (`pageSizeOptions`) and `:2692` (`pageSize`) — *"a new key on a Zod object schema"*. Textually right, semantically inverted for this diff: both members were already writable through `z.unknown()`, which accepted everything; what the diff does is **bound** them. That is a limitation of the matcher, not a signal about this PR, and it is in the acceptance notes below rather than repaired here. ## Acceptance notes *The two paragraphs below were added by the `domain:spec#3` seat after the body's single dev write, on the dev's own hand-over; ⛔ a dev writes a PR body once, at creation.* **The migration registry, with four open PRs adding entries to it.** Mine, objectstack-ai#19090, objectstack-ai#19084 and objectstack-ai#18319 each add one semantic entry. Identity cannot collide silently: the entry id IS the identity and the **filename is a function of it**, so a duplicate would be a loud git add/add conflict — the generator says so in as many words, and the four ids are four distinct files. Order is **derived** `(major, id)` from the directory listing, with no index file and no positional consumer (`migrations/chain.ts` keys by MAJOR, `MIGRATIONS_BY_MAJOR[m]`), so a clean text merge cannot express a wrong *meaning* — the `18.` prefix is the protocol-major bucket, not a sequence number. The gate is `pnpm --filter @objectstack/spec check:migration-registry`, run at exit 0 (「229 semantic, 195 retired-key, 181 retired-def」 current): it proves the emitted regions equal what the entries directory says, so a merge that dropped one side reds and one that kept both out of order reds too. Adjacency measured over the 141 existing `18.*` entries plus the four in flight: **7 / 49 / 61** existing entries lie between mine and objectstack-ai#19090 / objectstack-ai#19084 / objectstack-ai#18319 — no pair is adjacent, and the register's own insertion-only property then predicts a clean, current union whatever the landing order.⚠️ And `registry.ts` is deliberately **NOT** in the `merge=os-regen` register (classified MIXED, 「a deferral would launder the prose」), so a conflict there is **loud and a human's** — the silent-drop class does not reach it. **The hand-written docs negative, recorded so it is not reopened.** Probe: hand-written `content/docs` trees (excluding `references/` and `releases/`) authoring a `pageSize` value this narrowing refuses (`0`, negative, decimal) → **ZERO**. **Lit control, same instrument:** it does find authored `pageSize` occurrences — `content/docs/api/data-api.mdx:42` (`?pageSize=5`) and `content/docs/api/error-catalog.mdx:151` — over 2 hand-written pages and 9 pages including the generated tree, so the zero is a reading rather than a dead grep. **Attribution, which is the part that matters:** neither control hit is this door's `pagination.pageSize` — `data-api.mdx` documents `pageSize` as an *unknown REST query parameter* refused in favour of `top` / `$top` / `limit`, and the remaining pages are the metadata response shape, the object page and the metadata-plugin page. Four different `pageSize` members, none of them this one. ⇒ nothing owed on the hand-written side; the generated `content/docs/references/ui/component.mdx` already moved in this diff. The attribution step is the prescription of **objectstack-ai#19093**, filed today after a name-based hit produced a false stop-the-line alarm on a sibling PR. Observations found in passing. ⛔ None is filed as a card by this PR, and none is in its scope. - **The widening-tells matcher cannot tell a narrowing-inside-a-bag from a widening.** A PR that honestly declares `Clause-②: no (narrowing)` — a legal, precedented declaration (`.changeset/17499-groupbyfield-non-padded.md` carries exactly it) — and bounds a member inside a previously-`z.unknown()` bag is blocked at exit 4 by a T1 tell that names the bound as a widening, because the matcher reads the added key text and not the member's prior schema. Reproduced on this diff, above. The honest declaration is the blocked one. The successor: the next accept-set narrowing on this board. Dedupe words: `widening-tells T1 narrowing inside z.unknown bag`, `check-widening-tells false tell narrowing`, `clause-2 no narrowing blocked exit 4`. - **`frozenColumns: z.number().optional()`** on this same door (`component.zod.ts`) is unbounded, and the renderer reads it as a leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no measured consumer breakage, and it is not this card's member. Noted, not filed. The successor is any future PR on this door's numeric members. - **`pagination: false` / `pagination: true` on `data-table` / `object-data-table`** is authored in objectui and those props are not declared in `ComponentPropsMap` at all, so nothing in this repo judges them. Noted, not filed; that is the sibling repo's declaration surface, not this door's. ## Notes for the reviewer - ⛔ This PR does **not** hang, clear or touch `needs:contract-review`, and writes **no label** — both carriers are the seat's write. `Clause-②: yes` is here because triage ruled it; ⛔ this author does not review its own clause-② verdict. - `packages/spec/api-surface-declarations/ui.txt` moved because the declaration text moved. PR objectstack-ai#19024 removes all 17 of those shards; a deletion-versus-modification conflict there resolves in favour of the deletion and is expected — ⛔ not pre-solved here. - No governed surface is in the diff (checked against `GOVERNED_SURFACES` in `scripts/pm/check-governed-merges.mjs`): `docs/audits/` is not `docs/adr/`. - objectui#9853 is the consumer half's card and objectui#9896 its landed repair; this is the declaration half and was never a prerequisite for it. objectstack#18972 names this same class on the declaration side, and objectstack-ai#19083 landed its `scale` instance three commits before this branch's merge base. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ease pair it really spans (objectstack-ai#19115) Fixes objectstack-ai#18978 Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped; the schema still ACCEPTS a record without it. Contract-review tier. `spec-changes.json`'s `aggregate.added` / `aggregate.removed` are filled by a release-time api-surface diff of the artifact being published against the previously **published** one, so they span **one release** — under a record keyed by protocol major (`from: 10, to: 17`), with every entry carrying only `since: 17` / `removedIn: 17` and `perMajor[16 → 17].added` sitting at `0` beside it. Nothing in the file distinguished one minor's slice from the whole major-boundary delta. --- ## 1 · The defect, re-measured on a real published artifact Instrument: `curl` the Release asset through the REST API, then recompute the delta with a **hand-written flattener in Python** (not this repo's code) over the two published tarballs' own `api-surface/` shard directories. | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **Release asset** `aggregate.added` | **225**, `since` counter `{17: 225}` | | same asset, `aggregate.removed` | **51**, `removedIn` counter `{17: 51}` | | same asset, `aggregate.from` / `aggregate.to` | `10` / `17` | | same asset, `perMajor[16 → 17]` | `added: 0, removed: 0` (converted 57, migrated 77) | | same asset, `release` section | **absent** (generated 2026-09-09, before objectstack-ai#18889) | | independent recompute, `npm pack` 17.3.0 vs 17.4.0 `api-surface/` | **added 225, removed 51** | | set equality, asset arrays vs recompute | `added` **True**, `removed` **True**; 0 only-in-asset, 0 only-in-recompute, both directions, both arrays | So the published arrays are, byte for byte, the **17.3.0 → 17.4.0** one-minor delta, wearing a `10 → 17` label. Cross-check: PR objectstack-ai#17080's own changeset states the same pair as "gained 225 exports and lost 51". ### One refinement to the card's premise, stated because it moves a date, not a verdict | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | `@objectstack/spec@17.3.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | npm publish time of 17.4.0 | `2026-09-09T03:57:51.929Z` | | merge time of objectstack-ai#18889 (`8b4890343`) | `2026-09-18T11:16:13+00:00` | ⇒ **no published tarball carries the mislabelled arrays yet.** The lane that will is on `origin/main` today: `release.yml` runs `release-spec-changes.sh --prepare` (line 1251) and `--verify` (1260) **before** the publish, then `--attach` (1355), and `--prepare` invokes the generator with `--previous-package`. The card's "reach is new" premise therefore holds as a property of the lane, and the first tarball to carry it is the next publish. Today's carrier is the Release-page asset, measured above. This is a sharpening, not a disproof — nothing in the card's argument depends on a tarball already existing. --- ## 2 · The A/B legs, re-taken Base: `origin/main` at `07c6f822e`. Previous artifact: `npm pack @objectstack/spec@17.3.0`, unpacked. Both legs write the real snapshot path, so each was copied out and the tree restored by `git checkout HEAD -- packages/spec/spec-changes.json` with the blob hash re-read each time (`9dbc98682…` in, `9dbc98682…` out, `git diff HEAD` empty, `git status --porcelain` empty). | leg | what ran | result | |:--|:--|:--| | **A** | HEAD generator, `--previous-package PKG_DIR` | `aggregate.added` **399** (`since` counter `{17: 399}`), `aggregate.removed` **302** (`removedIn` counter `{17: 302}`), `perMajor[16 → 17]` **0 / 0**, `release` 17.3.0 → 17.4.0 with 399 / 302 | | **B** | generator at `43f4766889e` — objectstack-ai#18889's parent, verified **0** occurrences of the string `--previous-package` on disk and 3 of `--previous-surface` — invoked with `--previous-surface` | `aggregate`, `perMajor`, `protocolVersion`, `supportFloor`, `migrateCommand` all **canonical-hash identical to leg A** (`aggregate` = `d8c3e5c4303c2ecc` on both) | Whole-document diff between the two legs: the `release` key (leg A only) and `$comment` (which objectstack-ai#18889 extended). Nothing else. ⇒ **the computation is pre-existing**, exactly as the card claimed. One reading the card did not state, and it is the sharpest one: in leg A, `aggregate.added` / `aggregate.removed` are **set-identical to `release.added` / `release.removed`**. The aggregate record does not merely resemble a one-release slice — it *is* the release slice, under a major-resolution header. --- ## 3 · ⭐ The consumer survey the card named as unmeasured **Question:** who reads `aggregate.added` / `aggregate.removed` today? ### Radius, declared | # | in radius | how read | |:--|:--|:--| | R1 | `objectstack-ai/objectstack` @ `origin/main` `07c6f822e` | `git grep -I` over tracked **and** untracked files, whole tree, no `head` anywhere | | R2 | `objectstack-ai/objectui` @ `origin/main` `05a49f2ee` (fetched for this survey) | `git grep -lI PATTERN origin/main` | | R3 | the published tarball's own contents | `npm pack` 17.3.0 and 17.4.0, plus `packages/spec/package.json` `files[]` | | R4 | the documented / prescribed consumers | `content/docs/upgrading.mdx`, `skills/objectstack-upgrade/SKILL.md` (the **published** skill catalog), `docs/adr/0087` | **Outside the radius, named as outside it:** the `objectstack-ai/cloud` repository (not checked out in this container); any third-party or private consumer of the npm artifact or of the Release-page asset; and the `spec_changes` MCP tool, which is **prose only** — `git grep spec_changes` over R1 returns docs, ADRs, changelogs and code comments and **zero implementation**, so there is nothing there to read anything. ### Instrument, in two stages - **Stage 1 — population.** Every site naming the literal `spec-changes.json`, plus every site naming a key that is distinctive to this manifest (`perMajor`, `supportFloor`). Enumerable and small; each hit was then read. - **Stage 2 — field classification.** For each member of that population, which top-level keys it actually reads. ### ⭐ Lit controls, so a zero is a reading | control | instrument | result | |:--|:--|:--| | L1 · a site that provably reads a field of `spec-changes.json`, found by stage 1 | `git grep -n "spec-changes\.json"` | **found** `packages/cli/src/utils/spec-release-changes.ts:80`, which reads `doc.release` at line 106 — a real, shipping reader | | L2 · the distinctive-key instrument is not dead | `git grep -n perMajor` / `supportFloor` | **found** the producer, two gate fixtures, and the published `skills/objectstack-upgrade/SKILL.md:219` + its `node -e` snippet at 229-236 | | L3 · the instrument reaches objectui at all | `git grep -lI PATTERN origin/main` in `../objectui` | `@objectstack/spec` → **1628** files; `api-surface` (another published spec artifact) → **3** files | ### Result | consumer | radius | reads | reads `aggregate.added` / `removed`? | |:--|:--|:--|:--| | `packages/cli/src/utils/spec-release-changes.ts:106` (ships in `@objectstack/cli`) | R1 / R3 | `doc.release` and the lengths of its four arrays | **no** | | `scripts/check-release-spec-changes.mjs` `aggregateIds()` | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId`, `release.*` | **no** | | `packages/spec/scripts/build-spec-changes.ts` `previousRelease()` (reads the PREVIOUS tarball) | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId` | **no** | | `scripts/check-adr-0087-registration.mjs` (parser-rot witness) | R1 | `migrationId` occurrences | **no** | | `skills/objectstack-upgrade/SKILL.md` — **published** to customer projects | R4 | `perMajor[].converted`, `perMajor[].migrated`, `protocolVersion`, `supportFloor` | **no** | | `content/docs/upgrading.mdx` | R4 | `.release.*`; and, for withdrawals only, `.aggregate.converted[].conversionId` / `.aggregate.migrated[].migrationId` | **no** | | whole `objectui` repository | R2 | nothing — `spec-changes` → **0** files, `perMajor` → 0, `supportFloor` → 0, `spec_changes` → 0 | **no** | | `spec_changes` MCP tool | R1 / R4 | does not exist as code | n/a | | `scripts/regen-artifacts.mjs`, `check-regen-pending.mjs`, `objectui-changeset-digest.mjs`, `check-published-files.mjs`, `docs-audit/affected-docs.mjs` | R1 | the **path**, as a ledger row — never a field | **no** | ⇒ **Zero readers of `aggregate.added` / `aggregate.removed` in the reachable radius.** Every field-level reader of `aggregate` reads `converted` / `migrated` only. Confirming probes: `git grep -nE "aggregate(\.|\[[\"'])(added|removed)"` over R1 returns **0 rows**; the loosened, case-insensitive variant returns 11 rows, all the English phrase "aggregate added to the spec" about SQL aggregate functions. **But there is a declared contract, and it is the one the defect breaks.** `content/docs/upgrading.mdx:338` says, of this very field: "The same file's `aggregate` and `perMajor` records are unchanged and still answer the **major-boundary question**." They do not. That sentence is the class-(b) contract text — a machine-readable surface that does not say what it means — and it is what makes this a defect rather than an unused field. ### Why the survey licenses the shape taken The dispatch allows two shapes: **gate** the aggregate arrays as `release.*` is gated, or **relabel** them at the resolution they actually carry. Gate-only cannot be the whole fix here, and that is a measurement, not a preference: there is no computable "correct" 10 → 17 export delta to gate against, because tarballs before protocol 15 ship no `api-surface` snapshot at all. A gate that merely refused today's shape would wedge every release until the producer changed — and the producer changing **is** the relabel. So the gate is not an alternative to the relabel; it is the **negative control for it**. And with **zero** readers, relabelling is free: nothing downstream can break, so the honest fix is available at no migration cost. That is what the survey buys. ⛔ **Not taken, and reported instead:** removing the fields, or ceasing to emit them. The survey lands exactly where the card guessed it might — nobody reads them — so the removal question is live, and it is the maintainer's. See `## Acceptance notes`. --- ## 4 · What changed A record whose export arrays are non-empty now carries the version pair they were diffed between: ```json "aggregate": { "from": 10, "to": 17, "surfaceScope": { "fromVersion": "17.3.0", "toVersion": "17.4.0" }, "added": [], "removed": [] } ``` - **`packages/spec/src/migrations/spec-changes.ts`** — `SpecSurfaceScopeSchema` + `SpecSurfaceScope`, an optional `surfaceScope` on `SpecChangesSchema`, `SurfaceDiff.scope`, and `surfaceScopeProblem(record)`, which is the refusal. The record spreads the key in rather than assigning `undefined`, so a record with no export diff serialises exactly as before. - **`packages/spec/scripts/build-spec-changes.ts`** — reads the previous version off the previous artifact's own `package.json` (`--previous-package PKG_DIR`, or the sibling of a `--previous-surface` snapshot), OMITS the arrays loudly when it cannot, and refuses outright to write a non-empty unlabelled array. - **`scripts/check-release-spec-changes.mjs`** — `verifyAggregateSurface()` recomputes the aggregate's claim from the same two tarballs the release section is checked against, and refuses an absent, mislabelled or untrue scope in both directions. Self-test roster **15 → 23** batteries. The failure headline now names which claim disagreed. - **`packages/spec/src/migrations/spec-changes-surface-scope.test.ts`** — new. - Regenerated: `packages/spec/spec-changes.json` (one line — its `$comment`) and `packages/spec/api-surface-declarations/root.txt` (+6 / -0). ⛔ **Not narrowed on purpose.** `SpecChangesSchema` still accepts an unscoped diff, because every manifest published so far carries one and a schema that refused them would narrow what an already-shipped artifact parses as. The refusal lives at the producer and at the publish gate. ⛔ **`packages/spec/src/migrations/registry.ts` was not touched** (held by objectstack-ai#19095, objectstack-ai#19090, objectstack-ai#19084, objectstack-ai#18319). The change is additive, so it declares no ADR-0087 disposition and needs no migration entry: `node scripts/check-adr-0087-registration.mjs --base origin/main` → "this PR adds no declared-breaking changeset". `scripts/regen-artifacts.mjs` (held by objectstack-ai#19024) and `content/docs/releases/**` were not touched either. The public entry barrel `packages/spec/src/migrations/index.ts` was deliberately left alone, which is why `check:api-surface` is green with no export-name churn. --- ## 5 · ⭐ Acceptance controls ### Control 1 — a test that fails on today's composition (acceptance 1) Ablation via `node scripts/ablation-replace.mjs`, which proves the mutation reached disk before running anything: ```text ablation-replace: anchor "...(surfaceDiff.scope ? { surfaceScope: surfaceDiff.scope } : {})," x1 (before) ablation-replace: anchor x1 -> x0 ablation-replace: replace "// ABLATION: the composer drops the scope..." x0 -> x1 ablation-replace: blob 2e046d0 -> d0c1189d0f233a8d46b2641812713acf33a50181 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2e046d0 -> d0c1189d0f23 VITEST_EXIT=1 Test Files 1 failed (1) Tests 2 failed | 5 passed (7) ablation-replace: blob after restore 2e046d0 ablation-replace: blob at HEAD 2e046d0 ablation-replace: ok restored: blob == HEAD (2e046d0) and `git diff HEAD` is empty ``` The reported failure is the real one: `expected 'the 10 → 17 record carries 2 added and 1 removed export(s) with no surfaceScope…' to be null`. Unablated: **7 / 7 pass**. No ablation artefact remains — restore proved by blob equality with `HEAD` and an empty `git diff HEAD`, not by an exit code.⚠️ Reported honestly: the first run of this ablation piped vitest into `tail`, so the wrapper printed `command exited 0` while the suite had failed. The run above redirects first and captures `$?` before any pipe. Only the second reading is cited. ### Control 2 — ⭐ preserved truth (acceptance 2), shown rather than asserted Same generator invocation, same real 17.3.0 tarball, before the fix and after; every record compared by canonical JSON: ```text perMajor identical=True protocolVersion identical=True supportFloor identical=True migrateCommand identical=True release identical=True aggregate MINUS surfaceScope identical=True (the only added key: {'fromVersion': '17.3.0', 'toVersion': '17.4.0'}) $comment identical=False (documents the new key) ``` And on the **committed** artifact, per-key against `HEAD`: `aggregate` unchanged, `perMajor` unchanged, `protocolVersion` unchanged, `supportFloor` unchanged, `migrateCommand` unchanged, `$comment` changed — a one-line diff (`1 insertion, 1 deletion`). The per-release section objectstack-ai#18889 added is untouched in both readings, and `composeReleaseChanges` still returns exactly its six keys (pinned in the new test). Two further preserved-truth readings: all **15** pre-existing gate self-test batteries still pass unchanged, and `pnpm --filter @objectstack/spec check:generated` reports "All 16 generated artifacts are up to date". ### Control 3 — ⭐ a negative control that distinguishes fixed from switched off (acceptance 3) The gate run against four constructed publish trees, each carrying the real committed `api-surface/` and a real `package.json`, with the real unpacked 17.3.0 tarball as `--previous`: | input | what it is | gate | |:--|:--|:--| | `good` | the post-fix generator's own output | **EXIT=0** — "release 17.3.0 → 17.4.0 verified … 399 added, 302 removed … aggregate export diff 17.3.0 → 17.4.0 verified: 399 added, 302 removed." | | `bad-prefix` | the **genuine, unmodified pre-fix artifact** — what `main`'s generator produces today | **EXIT=1** — "aggregate.surfaceScope is absent while aggregate.added/removed carry 701 export(s). … Expected { fromVersion: "17.3.0", toVersion: "17.4.0" }." | | `bad-unscoped` | post-fix output with `surfaceScope` deleted | **EXIT=1**, same refusal | | `bad-wrongscope` | `surfaceScope.fromVersion` set to `17.2.0` | **EXIT=1** — "the export diff was taken against a different release." | The `bad-prefix` row is the load-bearing one: the new gate refuses the artifact today's code actually produces, so it is a check that can still fail rather than one that was switched off. Eight further refusals are pinned as self-test batteries (absent scope, wrong `fromVersion`, wrong `toVersion`, an invented export, an omitted real removal, a claim the previous tarball could not have produced), each alongside two GREEN batteries — a matching scoped claim, and the unscoped-empty registry-only projection that must stay accepted. The producer half, both directions: ```text $ tsx scripts/build-spec-changes.ts --previous-surface ORPHAN_DIR/api-surface No aggregate export diff: the previous artifact at ORPHAN_DIR/api-surface carries no readable package.json, so the version pair the diff spans cannot be read. Omitting `added`/`removed` — an unlabelled one-release slice under the major-keyed aggregate record reads as the whole from → to delta. -> aggregate added 0 removed 0 surfaceScope None $ tsx scripts/build-spec-changes.ts --previous-surface PREV_PKG/api-surface -> aggregate added 399 removed 302 surfaceScope {'fromVersion': '17.3.0', 'toVersion': '17.4.0'} ``` ### Control 4 — the card's own numbers, re-measured after the change (acceptance 4) Instrument: HEAD generator, `--previous-package` pointed at the unpacked published 17.3.0 tarball; counters computed by `collections.Counter` over the emitted JSON. | reading | post-fix value | |:--|:--| | `aggregate.from` / `to` | `10` / `17` (unchanged — it still answers the major question for `converted` / `migrated`) | | `aggregate.added` | 399, `since` counter `{17: 399}` | | `aggregate.removed` | 302, `removedIn` counter `{17: 302}` | | `aggregate.surfaceScope` | `{fromVersion: 17.3.0, toVersion: 17.4.0}` ← **new; this is the fix** | | `perMajor[16 → 17]` | `added: 0, removed: 0` (unchanged, and now honest by construction: the record says nothing about exports) | | `release` | 17.3.0 → 17.4.0, 399 added / 302 removed (unchanged) | --- ## 6 · Verification | what | result | |:--|:--| | `pnpm --filter @objectstack/spec build` (forced fresh, under the shared verify lock) | `VERDICT command-exit 0`; `check-dts-emitted: 34/34` | | `pnpm --filter @objectstack/spec typecheck && … test` (under the lock) | `VERDICT command-exit 0` — **495 test files, 14527 tests, all passing** | | `node scripts/check-release-spec-changes.mjs --self-test` | EXIT=0 — **23 batteries pass** (15 pre-existing + 8 new) | | `pnpm --filter @objectstack/spec check:generated` | EXIT=0 — all 16 artifacts up to date | | `pnpm lint` (full repo union, at final commit `0c548868c`) | EXIT=0 — **6878 files linted, 0 errors, 0 warnings** (`--format json` counts) | | `pnpm check:nul-bytes` | EXIT=0 — 8952 text files, no raw control bytes | | `@objectstack/cli` unit tier, `src/utils/spec-release-changes.test.ts` | 6/6 pass — the one downstream reader of this artifact | | gate families derived from the diff (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`) | 103 commands over 7 paths; **43 run green** locally, listed in the report | | `pnpm check:type-check-debt` | **EXIT=3 · PREREQUISITE NOT MET — NOT MEASURED**: `--re-measure` needs the whole workspace build closure on disk and only `packages/spec` was built. Its own words: "This is NOT a pass and NOT a finding". Its non-re-measure invariants reported 0 findings on all three layers. Left to CI, which builds the closure first. | Downstream reach, with a lit control: `git grep -nE "\b(SpecChangesSchema|SurfaceDiff|SpecSurfaceAdd|SpecSurfaceRemove)\b"` outside `packages/spec` returns **0 rows**; the same instrument finds `composeMigrationChain` (a sibling export of the same module directory) in `packages/cli/src/commands/migrate/meta.ts`. ⇒ no package outside `packages/spec` names any changed declaration, so no other package's tests are owed. Export **names** are unchanged (`check:api-surface` green); only declaration text moved (`api-surface-declarations`, +6 / -0). --- ## Acceptance notes 1. ⭐ **The removal question is live, and it is the maintainer's.** The survey found **zero** readers of `aggregate.added` / `aggregate.removed` in the whole reachable radius. The card itself floats "if the answer is nobody, the cheapest honest fix may be to stop emitting it". It was ⛔ **not** implemented here — removing a published machine-readable capability is a maintainer decision — and this PR makes the surface honest instead, which is strictly compatible with a later removal. Recorded as an open question. 2. **`content/docs/upgrading.mdx` is corrected here, not merely reported.** Line 338 said 'The same file's `aggregate` and `perMajor` records are unchanged and still answer the major-boundary question'. That is true of `perMajor`, and of `aggregate.converted` / `aggregate.migrated`, which are registry-derived across the whole range — and it was never true of `aggregate.added` / `aggregate.removed`. The page was the declared contract this artefact did not keep, so correcting it is the doc half of this defect rather than opportunistic cleanup. The path was measured FREE of open-PR holders first (32 open PRs, 364 file rows, instrument lit by all four holders of the migrations registry). 3. **A deliberate boundary in the new gate, so nobody reads it as an oversight.** It refuses a *wrong* aggregate claim; it does not *require* the published artifact to make one. An aggregate with empty arrays and no `surfaceScope` is accepted, because that is the honest registry-only projection. Turning "must not lie" into "must speak" would be a new publish requirement, and that call is not this gate's. The residual hole is narrow: a bug that silently emptied `aggregate.added` while `release.added` stayed correct would pass. Worth a card if the maintainer wants the stronger rule. 4. **`--previous-surface` has no caller left in the repository.** `git grep -- "--previous-surface"` finds only the generator's own argv parsing and its docblock; every lane uses `--previous-package` (`scripts/release-spec-changes.sh:87`). It was kept working — and taught to derive its scope from the snapshot's sibling `package.json` — rather than retired, because retiring a flag is not this card. 5. **`cut-rc.yml` attaches, and never prepares.** It calls `bash scripts/release-spec-changes.sh` with no mode, which defaults to `--attach`, so the RC lane uploads the committed registry-only manifest and never runs `--verify`. Not a defect (the committed copy claims nothing), and not this card — noted because it is the one lane the new gate never sees. 6. **No label was applied by this PR.** `Clause-②: yes` means it and objectstack-ai#18978 owe `needs:contract-review`; that label is the seat's to apply and ⛔ never this branch's to clear. 7. **The two regenerated artefacts were written by the repo's own generators, never by hand.** `packages/spec/spec-changes.json` by `pnpm --filter @objectstack/spec gen:spec-changes`; `packages/spec/api-surface-declarations/root.txt` by `pnpm --filter @objectstack/spec gen:api-surface-declarations`. Both were named stale by `pnpm --filter @objectstack/spec check:generated` first, and only those two were regenerated (`--fix` is deliberately narrow). No `origin/main` merge was performed on this branch, so the `merge=os-regen` silent-resolution hazard on that path was never entered. 8. **The docs-drift bot's three hand-written rows, answered.** `content/docs/api/client-sdk.mdx` and `content/docs/kernel/contracts/metadata-service.mdx` are **still accurate**: both were anchored by a NAME COLLISION on the generic identifiers `fromVersion` / `toVersion` between this PR's new published-version STRINGS and the REST metadata-history routes' INTEGER version parameters (`rest-server.ts:8209` reads `body.toVersion` for `POST /meta/:type/:name/rollback`; `client-sdk.mdx:229-230` spells the SDK keys `from` / `to`; `metadata-service.mdx:87` declares `version: number`). Neither page mentions `spec-changes` at all. `content/docs/upgrading.mdx` is the one genuinely-mine row and is corrected in this PR. ⛔ `content/docs/releases/v17/17-1.mdx` is release-owned and was not edited — it is also **not wrong**: the same collision put it there, its only mention of the route is line 294 in a security context, and it never names `spec-changes`. 9. **The bot's own blind spot, answered by reading rather than by trusting its run.** It declared that `api-surface-declarations/root.txt` and `spec-changes.json` yielded no anchor, so pages documenting those are outside its run — and `spec-changes.json` is this card's subject. A full read of `content/docs/**`, `docs/**` and `skills/**` finds **exactly one** page stating a claim about the aggregate export arrays' resolution: `upgrading.mdx:338`, corrected here. The published `skills/objectstack-upgrade/SKILL.md` points only at `perMajor[].converted` / `perMajor[].migrated` / `protocolVersion` / `supportFloor` — all unaffected and all still true. `content/docs/releases/v15.mdx:521-523` claims only that the file is generated, ships and attaches: still accurate. `docs/adr/0087:210-213` states no falsehood (its 'compose' claim is about the registry-derived arrays), though it is where the ambiguity originates — a governed-surface question, left to the maintainer. <sub>⚠️ Notes 2, 7, 8 and 9 were written into this body by the dispatching seat (`Seat: domain:spec#3`, `session_019srGWGCBBCBHqcDoRZpQRh`) at 2026-09-18T21:06Z, from the implementing dev's final report. The dev correctly refused to PATCH this body: `.claude/agents/os-dev.md:56` says the PR body is written once, on the call that opens the PR, and later corrections are named in the report for the seat to write — and `:184` makes that clause govern over any dispatch word. ⛔ Nothing else in this body was touched, and ⛔ no verdict about the diff is written here: the clause-② review is an isolated at-tier reviewer's, and `needs:contract-review` stays on both carriers until it lands.</sub> --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…hipped prescriptions were denying a door that exists (objectstack-ai#19234) Fixes objectstack-ai#17487 Clause-②: no ## The defect, and its direction Three shipped, customer-facing prescriptions in `@objectstack/spec` stated in the present tense that the runtime confirmation door had not shipped. It has: `actionConfirmationRefusal` is called pre-dispatch by `invokeBusinessAction` in `@objectstack/runtime`, and the MCP `run_action` tool grew the `confirm` member in the same change (the card behind it, objectstack-ai#15942, is done — `state_reason=completed`; its changeset `action-confirmation-gate-enforced` is still pending, so the door is on `main` and not yet released). So the published text denied a door that exists, and it failed in the dangerous direction: an author who reads it concludes the safety flag stops nothing, and either arranges a human in the loop some other way or stops setting the flag — losing the gate at the moment it starts working. That is the ADR-0049 false-compliance class with the sign flipped. ## Re-derivation — all three sites read on today's `origin/main` Triage's unblock comment verified site 1 only and said the other two were unmeasured. All three were re-read at merge base `805811e0d`. | # | Path | Current text | Verdict | |---|---|---|---| | 1 | `packages/spec/src/ai/tool.zod.ts` — `TOOL_RETIRED_KEY_GUIDANCE.requiresConfirmation` | "the declaration is the contract, not yet the behaviour — the runtime door that performs the refusal ships separately, and until it does, setting the flag does NOT stop an unconfirmed call. Do not try to verify the gate by invoking the operation without the member: until that door lands, such a call simply RUNS." | **FALSE today** | | 2 | `packages/spec/src/migrations/entries/semantic/17.tool-requires-confirmation-retired.ts` — `replacement` | "The refusal is DECLARED, not yet performed — the runtime door lands in objectstack-ai#15942, so until then the flag stops nothing on its own and the human in the loop is still yours to arrange" | **FALSE today** | | 3 | the same file — `acceptanceCriteria` | "Do NOT try to 'prove the gate' by invoking the operation without the confirmation member: the runtime door that refuses lands in objectstack-ai#15942, so before that ships the call is not refused, it RUNS the destructive operation." | **FALSE today** | **Correction to the card's count of the carriers.** The card names the `spec-changes` entry, the upgrade guide and the `os migrate meta` projection as if they were separate sites. They are not: all three are projections of the **one** ADR-0087 D3 entry file above. The measurement is therefore **three false prescriptions living in two source files**, plus three generated artefacts that carry them (`src/migrations/registry.ts`, `spec-changes.json`, `docs/protocol-upgrade-guide.md`), all regenerated here by `check:generated --fix`. Sweep radius for "is that all of them": eleven denial phrasings grepped repo-wide (`not yet the behaviour`, `ships separately`, `not yet performed`, `stops nothing`, `simply RUNS`, `until it does`, `until that door`, `door lands`, `yours to arrange`, `nothing server-side`, `no pause`), with `requiresConfirmation` lighting 10 files under `packages/spec/src` as the positive control. Two adjacent texts were read and left alone as **NOT A DEFECT**: `packages/spec/src/contracts/ai-service.ts` already states the gate in normative present tense, and `content/docs/ai/tools.mdx` says the retired **tool**-level key "returns only together with its enforcement", which is still true — the tool key has not returned. Two further readings are recorded under *Acceptance notes*. ## What the prose says now, and what holds it there Each prescription now states the refusal in the present tense **with the door's bounds**, because an unbounded "the platform refuses unconfirmed calls" is this same defect in the other direction. Read off the door's own docblock and its shipped changeset, never inferred: - the refusal is `ACTION_CONFIRMATION_REQUIRED`, 428, naming the action and the member `confirm: true`; - a GATE, not a queue — nothing is parked, and a refused call did not run: the gate sits before `loadActionSubjectRecord`, so no record is read and none written; - the enforced set is the doors that enforce the author's `ai.exposed` opt-in — today the action door reached from MCP `run_action`. REST `/actions` is **not** `ai.exposed`-gated and sits outside the gate, so an API-key agent on that route still needs its own human; - only the author's declared `ai.requiresConfirmation: true` refuses, and only the boolean `true` confirms; the wider `list_actions` heuristic advises and never refuses; - `confirm: true` is an unverifiable caller claim: the gate makes forgetting loud, it does not prove a human. `packages/spec/src/ai/tool-confirmation-prescription-tense.pin.test.ts` is the tie that was missing the first time — the prose was never bound to the function it describes, which is how it rotted. It reads the three shipped strings **and** the runtime door, and fails in both directions. **No pin was moved.** `ui/action-requires-confirmation-docblock.pin.test.ts` was read: it anchors on the `ai.requiresConfirmation` JSDoc in `ui/action.zod.ts` and on `actionLooksDestructive`, neither of which this diff touches, so it covers none of the three sites and stays as it is. ## Clause-②: no — the accept set did not move `check:authorable-surface` and `check:api-surface` are green with **zero** diff under `packages/spec/authorable-surface/` and `packages/spec/api-surface/`. The pin's last case feeds the same authored metadata in before and after: `tool.requiresConfirmation` still refused, a minimal tool still accepted, `action.ai.requiresConfirmation` still accepted for both `true` and `false`. What moved is string content inside `dist` and `spec-changes.json`, which is why a `patch` changeset is owed and present. ## Tests, and the reverse verification `pnpm --filter @objectstack/spec test` — 499 files / 14614 tests passed. `test:repo` — 34 files / 580 tests passed. `typecheck` — clean. New pin: 8/8. Three ablation legs, each mutated on disk through `scripts/ablation-replace.mjs` (anchor hit declared, blob hash proven to move), direction predicted before the run, restored and proven by blob hash against `HEAD` with `git diff HEAD` empty: | leg | mutation | predicted | observed | |---|---|---|---| | 1 | re-insert `The refusal is DECLARED, not yet performed` into the D3 entry's `replacement` | RED on "no shipped prescription denies the refusal" | RED, naming the replacement carrier | | 2 | rename the gate call inside `invokeBusinessAction` | RED on "the AI-facing door still calls the gate pre-dispatch" | RED | | 3 | make the REST `/actions` door name the gate | RED on the over-claim guard | RED | Leg 3's **first attempt was a no-op** and is reported as such: the replacement text still contained the anchor, so `ablation-replace` refused (anchor drop 0, not the declared 1) and nothing ran. It was re-anchored and re-run; the reading above is the re-run. ## Gates All 85 commands derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` for this diff were run locally and exit 0, exit codes captured before any pipe. Eight first returned a stale-`dist` or `PREREQUISITE NOT MET` result (exit 1 / exit 3 — not measured, not findings); they were re-run green after `pnpm --filter @objectstack/spec build` and a full `turbo run build` closure. `pnpm lint` (`eslint . --no-inline-config`, whole repo, no narrowing) exits 0 at `HEAD`. CI still owns its own farm: the five path-scheduled CI jobs, the 11 wide-population families and the artifact rosters are outside that 85 and are NOT MEASURED here. ## Acceptance notes Two readings taken while re-deriving, both **out of scope for this card** and neither edited here: 1. `packages/spec/docs/MCP_GUIDE.md` (around the "Side Effects" section) tells an author to gate side effects with "`ai.requiresConfirmation` on the underlying **action** (+ the HITL approval queue)" and then warns, in the adjacent block, that "nothing server-side pauses on it". The warning is correctly scoped to the MCP capability descriptor in that page's examples and is true of it; but the approval-queue requirement now overstates what the action-level flag needs, and the two paragraphs read together in the card's own dangerous direction. Not in the declared file surface. Reported for filing with dedupe words: `MCP_GUIDE`, `requiresConfirmation`, `HITL approval queue`, `nothing server-side pauses`, `confirmation gate`. 2. `content/docs/ai/actions-as-tools.mdx` — the "Human-in-the-loop approval" section still says that on the open MCP path "the approval step lives at the protocol boundary" (client-side prompting), and the numbered open-MCP action-gate list enumerates five gates without the confirmation gate that now sits between the param contract and the subject-record load. An omission against a contract that `@objectstack/spec/contracts` declares. Reported for filing with dedupe words: `actions-as-tools`, `human-in-the-loop`, `protocol boundary`, `run_action`, `confirmation gate`. Noted, not filed: `packages/spec/src/api/error-code-ledger.zod.ts` says of the `ACTION_CONFIRMATION_REQUIRED` row that "the door will assert this exact string by value" — a forward tense about something that is now true. It misleads nobody about the gate and it is provenance prose about the row's split registration, not a prescription. Successor: the next change that touches that ledger row. ## Occupancy Re-scanned at 2026-09-20T01:52Z over all 21 open PRs, with PR objectstack-ai#17076 (639 files) fully paged so no path is under-read. `packages/spec/src/ai/tool.zod.ts`, the D3 entry, `spec-changes.json`, `docs/protocol-upgrade-guide.md`, `vitest.repo-tests.json` and `src/ai/tool.test.ts` all read FREE. Firing controls in the same scan: `packages/spec/src/ui/component.zod.ts` HELD by objectstack-ai#19219, `packages/spec/src/ui/view.test.ts` HELD by objectstack-ai#19226; dark control (a nonexistent path) reads FREE. One reading to flag: `packages/spec/src/migrations/registry.ts` reads HELD by objectstack-ai#19223, objectstack-ai#19090 and objectstack-ai#18319 — it is a generated, `merge=os-regen` artefact and none of those three touches the D3 entry this diff edits, so the contention is the one the regen driver exists for rather than two hands on the same prose. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…tstack-ai#19283) Fixes objectstack-ai#19148 Clause-②: no `ActionSchema.undoable`'s published sentence said its `patch` names **exactly** the fields whose prior values are captured. An `operation: 'update'` action also writes whatever its `params` collect, so on any params-carrying action that sentence describes a **strict subset** of what the action writes — an Undo built to it restores part of the change and reports the action as undone. Triage settled the direction at comment 5747751639 and this PR executes it verbatim: > ⇒ **Scope: correct the `undoable` description** so it names what an undo must capture for each `operation`, and ⛔ do not narrow objectui#7551. All readings below were taken against `origin/main` = `1739f71879f`, the base this branch is cut from, between 2026-09-20T08:45Z and 2026-09-20T09:30Z. The base had not moved from the one the dispatch order names. ## What changed **`packages/spec/src/ui/action.zod.ts`** — the `.describe()` on `undoable`: FROM > `operation: 'update'` is the declared form of that action — its `patch` names exactly the fields whose prior values are captured. TO > `operation: 'update'` is the one declared operation and the declared form of that action: what the undo captures is the prior value of EVERY field the action writes — the merged write bag, `patch` UNDER the collected `params`, not `patch` alone. An action with no `operation` declares no write set, so nothing anchors the capture there. The `//` comment above the key carried the same claim ("its `patch` names exactly the fields written") and is corrected with it, now naming the executor symbol that settles the set. Prose only. No schema change, no refine, no key added or removed; the same author input parses identically before and after. That is the whole basis for `Clause-②: no`, and it is what the enqueue gate will read off the diff. ## Premise reading 1 — does a shipped runtime restore from the DECLARED set? **No. Every reader measured captures the WRITTEN set.** This is the stop condition the order named, and it does not fire. Server, `packages/runtime/src/action-execution.ts`, contract point 5: ```ts const data = declarativeUpdateWrite(action, params); // { ...patch, ...params } ... if (action?.undoable === true) { const undoData = {}; // typed Record of string to unknown in the source for (const key of Object.keys(data)) undoData[key] = prior[key] ?? null; ``` `declarativeUpdateWrite` in the same file returns `{ ...base, ...params }` with `base` the static `patch` — so `Object.keys(data)` IS the union. The `DeclarativeUpdateUndo.undoData` docblock next to it already reads "The prior value of EXACTLY the fields written". Console, `../objectui` at `dda8f3815df`, both readers key off the bag they actually send: - `packages/app-shell/src/hooks/useConsoleActionRuntime.tsx:487` — `for (const k of Object.keys(fields)) undoData[k] = rowRecord[k] ?? null;`, `fields` being the params bag with `bodyExtra` merged in, i.e. the same bag handed to `dataSource.update`. - `packages/app-shell/src/views/RecordDetailView.tsx:837` — `for (const k of Object.keys(params))`, `params` again being the bag handed to `dataSource.update`. ⇒ correcting the prose closes the divergence rather than moving it. No behaviour change is proposed or needed. ## Premise reading 2 — who else reads `undoable`? Instrument `git grep -n -w 'undoable'`, exit code captured before any pipe. Firing control: `ActionSchema` in the same file, 21 hits, exit 0. Dark control: `undoableZZZNOSUCH`, 0 hits, exit 1. 228 hits over 43 files in this repo; 128 hits over 27 files in `../objectui`. Readers of `ui/Action:undoable` whose behaviour depends on the flag: | Reader | Capture set | Verdict | |:---|:---|:---| | `packages/runtime/src/action-execution.ts:1991` | merged `{ ...patch, ...params }` | written set | | objectui `useConsoleActionRuntime.tsx:487` | the bag sent to `dataSource.update` | written set | | objectui `RecordDetailView.tsx:837` | the bag sent to `dataSource.update` | written set | | objectui `action-button.tsx:223` | forwards the flag only | no capture set | | objectui `ActionDefaultInspector.tsx` | the authoring checkbox | no capture set | Readers that restate the sentence rather than act on it — all corrected or regenerated here: the `.describe()` itself, the three generated reference tables it renders into (`ui/action`, `data/object`, `kernel/metadata-plugin`), and the hand-written protocol page. One reader consults the liveness ledger and not the sentence: `packages/lint/src/lint-liveness-properties.test.ts:109` asserts the lint stays silent on `action.undoable`. Unaffected — the ledger row is untouched. ## The three other `undoable` sites — both homonyms, and how that was decided The order named two files it had not classified. Both are homonyms, on three mechanical legs each rather than on how the word reads. **`packages/spec/src/api/export.zod.ts:478` and `:523`** (`ImportJobProgress` / `ImportJobResults` / `ImportJobSummary`) — homonym: 1. **Different surface id.** `packages/spec/authorable-surface.base.json` lists `api/ImportJobProgress:undoable` and siblings; the UI flag is `ui/Action:undoable`. Different schema, different namespace. 2. **Opposite direction.** It is a required `z.boolean()` the SERVER computes and the client reads — `packages/rest/src/rest-server.ts:846`, `undoable: importJobUndoable(row)`. `ui/Action:undoable` is an optional flag the AUTHOR writes and the runtime reads. 3. **Different referent.** "Whether this job can still be logically rolled back (undo log captured, terminal state, not yet reverted)" — a job-level boolean about an import's undo log. It names no field set at all, so there is no capture set for this card's sentence to be wrong about. **`packages/spec/src/system/migration.zod.ts:301`** — homonym, and not a schema member at all: the single hit is the English adjective inside a prose docblock ("Every one of those is undoable: a rejected write is retried, a tombstone is lifted on re-attach"). `git grep -n -w 'undoable'` on that file returns exactly one line and it is a comment. Two further hits the same grep turned up are the same import-job homonym reaching its consumers (`packages/client/src/index.ts:6989`, `packages/rest/src/rest-server.ts:846` and `:875`) and one is the adjective again (`scripts/pm/check-clause2-carriers.mjs:8263`, inside the word "un-undoable"). ## "For each `operation`" — the census that makes it a one-row rule `operation` is `z.enum(['update'])`. It carries **exactly one** member; `'delete'` and `'custom'` are refused at parse time with their own reason. So the per-operation capture rule triage asked for is a one-row rule, and the sentence states it as one rather than inventing a table with a single row. The sentence also covers the case the enum does not: an action that declares no `operation` at all. Nothing refuses `undoable: true` there — the refine chain in `action.zod.ts` says nothing about the pair — and the server builds no `undo` for it, because the `undo` envelope is built only inside `executeDeclarativeUpdateAction`. That case was previously undescribed; it is now named as un-anchored rather than left to be inferred. ## Verification Gates derived from this tree, not from a list: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, exit codes captured before any pipe into a TSV, reconciled with `--ran`. ``` Run reconciliation — 100 derived, 99 run, 1 NOT-MEASURED, 0 UNRUN. ✓ dispatch-gates --ran: 100 derived famil(ies) accounted for — 99 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3). ``` - `pnpm --filter @objectstack/spec run check:docs` — exit 0 (the gate that owns the three regenerated tables). - `pnpm --filter @objectstack/spec run check:generated` — exit 0, all 16 artifacts current. - `pnpm --filter @objectstack/spec test` — exit 0, 500 files / 14643 tests. - `pnpm --filter @objectstack/spec run typecheck` — exit 0 (tsc, scripts, test layer). - `pnpm lint` — the repo-wide `eslint . --no-inline-config`, exit 0 at `76eb07aeb63`. Run whole rather than narrowed, so no narrowing needs proving. - `pnpm check:nul-bytes` — exit 0; plus a hand sweep of the edited files for non-NUL control bytes, no hits. **NOT MEASURED (1):** `pnpm check:dual-build-cjs-loads`, recorded exit 3 — `PREREQUISITE NOT MET`, it reads built output for 83 packages and a repo-wide `pnpm build` does not fit the container's foreground limit. Exit 3 is that gate's own "nothing was measured" code, neither a pass nor a failure. It reads `dist/` loadability and this diff changes no export, entry or build config. CI runs it. No new test is owed: a prose claim is not a behaviour, and the text is already pinned mechanically — `check:docs` holds the three generated tables byte-equal to what the `.describe()` produces, so a future edit to the sentence cannot land without moving them. ## One file outside the claim's declared surface The claim declares `packages/spec/src/ui/`. `content/docs/protocol/objectui/actions.mdx:54` is hand-written and carried the identical false claim — "`undoable` has its anchor here: the patch names exactly the fields whose prior values are captured" — and is corrected in the same edit under the bounded same-defect exemption, with all four conditions measured: 1. **Same defect class** — the same claim about the same key, word for word. 2. **Mechanical** — the corrected wording transposes directly. 3. **Held by no open PR** — 26 open PRs, every file list read at 2026-09-20T08:52Z. The same scan clears `packages/spec/src/ui/action.zod.ts` and resolves the order's residual: objectstack-ai#19090 lands in `packages/spec/src/ui/bulk-action.zod.ts`, not `action.zod.ts`, so this PR is not second on it. 4. **No new verification surface** — the derived gate list is byte-identical with and without that file, 95 commands either way. The reviewing seat may want to amend the claim's file surface to match. ## Acceptance notes Out of scope for this PR, filed nowhere, each with the PR or reader that will reach it: - `packages/runtime/src/action-execution.ts:1992` — the comment on contract point 5 reads "EXACTLY the fields written — the patch names them". The code beside it keys off the merged bag and is correct; the trailing clause is the same conflation this card corrects, one file over. Not a defect (no behaviour depends on it) and outside this claim's surface. Successor: the next PR touching contract point 5. - `packages/runtime/src/action-declarative-update.test.ts:509` — the point 5 block pins the patch-only capture, the absent-field null and the no-`undoable`-no-`undo` zero, but no case declares `undoable: true` **and** params together, so nothing would fail if the executor ever narrowed to patch-only. The behaviour is correct today; the pin that would hold it is absent. Successor: the same PR as above, or a runtime-lane card if the reviewing seat would rather route it. - `packages/spec/src/ui/action.zod.ts:1109` — the EXECUTOR CONTRACT docblock still says "both keys are `planned` in the liveness ledger until they land". `packages/spec/liveness/action.json` flipped `operation` and `patch` to `live` on 2026-09-08. A different defect class from this card's, so deliberately not swept in. Successor: the next PR touching that docblock. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…s from its displayed scale (objectstack-ai#19442) Fixes objectstack-ai#19320 Clause-②: yes (widening) ⭐ Declared from the **measurement**, ⛔ not from the shape of the change: the accept-set delta over 1950 cells is **36 ADDED / 0 REMOVED**, so the narrowing arm is empty.⚠️ The seat rewrote this line from a backticked, prose-trailing spelling that the repo’s own `readClause2Line` reads as `{kind: near-miss, reason: describing}` — a near-miss is ⛔ not a declaration, and `Check Changeset`’s level axis would have had no input from this body. ✅ **Both halves of the ruling are now here.** The behaviour half (the validator's percent arm) and the `packages/spec` docblock half landed in the same branch; the docblock half needed a file outside the boundary this card was dispatched with, was reported rather than taken, and was then authorized by the dispatching seat. The closing keyword is therefore a closing keyword. ## The ruling this makes live Maintainer ruling batch objectstack-ai#161 item 3 letter B (objectui#9810, comment `5729749935`, 「其他同意」 2026-09-18T12:07Z). Quoted, not translated: > - `packages/spec` `FieldSchema.scale` docblock (and the field reference page): for `percent`, `scale` is the number of decimal places of the percentage-point value as displayed and entered; stored precision follows the storage scale (`fraction` ⇒ `scale + 2` places; `whole` ⇒ `scale`). > - `record-validator.ts` `max_scale` branch: when `def.type === 'percent'` and `percentScaleOf(def) === 'fraction'`, compare against `def.scale + 2`; a pin per storage scale (fraction `scale: 2` accepts `0.1234`, refuses `0.12345`; whole `scale: 2` unchanged). ## Premise re-verification — first-hand, on today's tip, with a lit control The card's premise was second-hand. Both halves were re-measured against `origin/main` at base `24162f95`, through the **real** record validator imported from the built `dist` of `@objectstack/objectql` — no harness, no source shortcut. | case | ruling B requires | measured BEFORE this PR | | --- | --- | --- | | fraction `scale: 2`, write `0.1234` (scale+2 places) | ACCEPT | **REFUSE** `max_scale` `{scale:2, actual:4}` | | fraction `scale: 2`, write `0.123` (scale+1) | ACCEPT | **REFUSE** `max_scale` `{scale:2, actual:3}` | | fraction `scale: 3`, write `0.33333` (the ruling's 33.333%) | ACCEPT | **REFUSE** `max_scale` `{scale:3, actual:5}` | | fraction `scale: 0`, write `0.33` | ACCEPT | **REFUSE** `max_scale` `{scale:0, actual:2}` | | fraction `scale: 2`, write `0.12345` (scale+3) | REFUSE | REFUSE (agrees) | | whole `max: 100, scale: 2`, write `12.34` / `12.345` | ACCEPT / REFUSE | ACCEPT / REFUSE (agrees) | **Lit control, same instrument, same run** — so the refusals above are a reading and not a dead instrument: the validator ACCEPTED `0.12` and `0.5` on the same field, and REFUSED for three *different* reasons — `max_value` on `max: 1` with `5`, `min_value` on `min: 0` with `-0.5`, and `invalid_number` on `'abc'`. `number` / `currency` / `slider` all refused at `scale + 1` in the same run. Docblock half, read at source: `FieldSchema.scale`'s `.describe()` (`packages/spec/src/data/field.zod.ts`) states the 0-100 platform ceiling and nothing about `percent`; `percentScaleOf`'s docblock (`packages/spec/src/data/percent-scale.ts`) states the fraction/whole rule and says nothing about `scale`. **Both halves of the card hold. The premise is TRUE.** ## Accept-set delta — measured in BOTH directions Both predicates (current and ruled) were run over an exhaustive corpus of 1,950 cells: 5 numeric field types x 5 `max` declarations x 6 `scale` values x 13 decimal-place counts. ``` corpus cells: 1950 unchanged: 1914 ADDED (accept set grows): 36 REMOVED (accept set shrinks): 0 declaration classes whose allowance moves: percent max=undefined, percent max=0.5, percent max=1 ``` - The narrowing arm is **empty** — 0 of 1,950 cells. Nothing that writes today stops writing; no stored value is re-read; no migration is implied. - Only fraction-stored `percent` moves. `percent` with `max` above 1, and `number` / `currency` / `slider` / `rating` at every `max`, are byte-identical in verdict. - ⇒ `Clause-②: yes (widening)` is what the measurement supports. It was dispatched as a claim to check; the claim survives the check.⚠️ **One flag for the contract review, not a re-adjudication.** The ruling's own Execution section declares `Clause-②: no`. The mechanical criterion in `pm-dispatch` is 「本卡放宽接受集或扩大公开面吗」, and the accept set is measurably relaxed, so the conservative routing arm is `yes`. The declaration is by design provisional (「按设计临时…⛔ 非终审」), so this is a routing difference to be recorded at review, not a change to the ruling. ## What this PR implements `packages/objectql/src/validation/record-validator.ts` — the `max_scale` branch gains its percent arm. The fraction/whole split is **read from the spec's `percentScaleOf`**, not re-derived from `max` at this seam, so the edit widget, the analytics wire and the validator keep answering from one source. The refusal envelope now reports the allowance that was **applied**: on a fraction-stored `scale: 2` field, `0.12345` is still refused and reports `constraint: { scale: 4, actual: 5 }`. Reporting the raw declaration beside a stored fraction's place count would render "must have at most 2 decimal places (got 5)" on a field that accepts four — a true refusal described by a false constraint. This is the one detail the ruling's letter leaves open; it is decided in the direction that keeps the machine-readable surface honest, and it is pinned. ## The spec half — and the boundary that gated it The ruling's first bullet is the `packages/spec` `FieldSchema.scale` docblock **and the field reference page it generates**. That is `packages/spec/src/data/field.zod.ts`, which was **outside** the four-file boundary this card was dispatched with, so it was reported before being touched rather than taken quietly. The two `packages/spec` paths the dispatch originally named (`numeric-column-representation.ts` and its test) are about the **DDL column** (`numeric_precision` / `numeric_scale`) and mention `percentScaleOf` only inside a prose comment — they are not part of this repair and are untouched. What landed, after the seat authorized the corrected surface: - `packages/spec/src/data/field.zod.ts` — `FieldSchema.scale`'s `.describe()` now states **both** meanings: what the number counts on a `percent` field (decimal places of the displayed percentage-point value) and what it permits in storage (`fraction` ⇒ `scale + 2`, `whole` ⇒ `scale`, every other numeric type ⇒ `scale`). Both halves go in the **describe**, not only in a source comment, because the reference page is generated from the describe and an author who reads only that page is the author the ruling is about. - `content/docs/references/data/field.mdx`, `data/object.mdx`, `system/migration.mdx` — regenerated by `pnpm --filter @objectstack/spec gen:schema && gen:docs`, ⛔ never hand-edited. **Exactly those three tracked files moved and nothing else**, which is what the pre-commit source/regeneration split was arranged to make legible: the source edits were committed first, so the regeneration commit's file list is the regeneration's own output. - `packages/spec/src/data/percent-scale.ts` — the optional cross-reference, **taken**. The card's own measurement table named *this* docblock as the one silent about `scale`, and `percentScaleOf` is the function the validator calls, so a reader who lands here should find the consequence rather than re-derive it. Written as a pointer, ⛔ not a second copy: the rule is stated once on `FieldSchema.scale` and enforced once in the validator. **Serial constraint re-measured for those paths** before any of it was written, same instrument as the dispatch used: 20 open PRs, `GET /pulls/N/files` each, **0 unreadable file lists**, and no open PR holding any of the eight paths. Lit control on the same run: `packages/spec/src/**` matches 7 open PRs (objectstack-ai#19398, objectstack-ai#19374, objectstack-ai#19373, objectstack-ai#19335, objectstack-ai#19314, objectstack-ai#19090, objectstack-ai#18319), so the zeros are absences the instrument could see. ## Verification **Ablation** — `scripts/ablation-replace.mjs`, anchor `? def.scale + 2` in the production file. -⚠️ The **first attempt was a no-op and its reading is void**: the replacement string was a prefix of the anchor, so its occurrence count could not rise, and the tool refused before running anything. Recorded rather than quietly retried. - Second attempt landed: anchor `x1 -> x0`, blob `2e2d3981d29a -> 7b082941b44f`, command executed, restore proved `blob == HEAD (2e2d398)` with `git diff HEAD` empty. - Under ablation: **8 failed / 100 passed (108)**. All 8 are in the new block and fail for the right reason — the fraction-stored writes are refused with `max_scale`, and the envelope/message read `2` where the ruling requires the applied `4`. - ⭐ **5 of the 13 new cases cannot discriminate, and are not counted as evidence**: the scale+3 refusal, the whole-percent pin, the other-numeric-types controls, the other-refusal-reasons control and the no-declared-scale control pass on both trees **by design** — they are anti-vacuity and lit-control pins, there so that a branch which simply stopped enforcing `scale` on percent fails this block too. **Gate family** — derived from the real changed paths with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, every command run with its exit code captured before any pipe, reconciled with `--ran` carrying the recorded codes: ``` 111 derived · 111 run · 107 green · 0 red · 4 NOT MEASURED · 0 unrun ``` - One real red was found and repaired in this PR: `check:error-code-casing` read the envelope pin's bare `code: 'max_scale'` as an ADR-0112 D1 emission because its recognizer window held no field-addressed neighbour. Naming the field is the repair and a stronger assertion. Re-run exit 0: "no unlisted lowercase error codes in 6371 scanned file(s)". - NOT MEASURED, each with its reason, none of them a red: - `check:dual-build-cjs-loads` — exit 3, PREREQUISITE NOT MET: reads built output, 66 packages have no `dist/` in this worktree. - `check:type-check-debt` — exit 3, PREREQUISITE NOT MET: needs the whole workspace closure built. - `check-plugin-teardown-shape.mjs --self-test` — exit 3: its positive-control fixture is pinned to a commit this shallow clone cannot reach. - `check-engine-split-ratio.mjs --days 90` — exit 2: refuses to compute an ADR-0076 D7 ratio on a shallow clone whose oldest visible commit sits inside the window. Counted as "run" by the reconciler (exit 2 is not the prerequisite code) but it measured nothing, so it is reported here as NOT MEASURED. - One gate **refused its prerequisite while spelling it `exit 1`**: `check:skill-examples` reported that `packages/client-react/dist` held no declarations, which is a refusal and ⛔ not a finding. Rather than report it as NOT MEASURED, the package closure was built and the gate re-run to a real verdict: exit 0, **258 prose examples type-check across 3 surfaces**, including the 10 spec-source TSDoc blocks — the surface this PR edits. - The two remaining `exit 3` families were **deliberately left unmeasured**: both need a whole-workspace build, and both are whole-tree families unrelated to a describe string and a validator arm. CI builds everything and measures them there. The `check:skill-examples` closure was built because that gate reads the spec source surface this diff touches — the choice is principled, ⛔ not a budget. - The reconciler's own verdict, DERIVED from the recorded codes rather than claimed: `111 derived famil(ies) accounted for — 108 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)`. **Suites and lint**, at the final commit: - `pnpm --filter @objectstack/objectql test` — **303 files / 5050 tests passed**, exit 0. - `pnpm --filter @objectstack/spec test` — **505 files / 14,752 tests passed**, exit 0; `pnpm --filter @objectstack/spec typecheck` exit 0. - `pnpm --filter @objectstack/objectql typecheck` — exit 0; `check:test-typecheck` OK, ledger unchanged at 40 files / 234 errors / 65 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` — exit 0, **all 15 generated artifacts up to date** against the edited `FieldSchema.scale`. Both gates the ruling's docblock half puts at risk are green by name: **`check:docs`** (the three regenerated reference pages) and **`check:authorable-surface`** (authorable surface + JSON schemas). `authorable-surface.base.json` did not move — a regular build never writes it. - `pnpm lint` — repo-wide `eslint . --no-inline-config`, exit 0 at `bb9f9274`, the final commit. The full union ran; no narrowing was needed, so no narrowing is claimed. - **The ablation reading still describes the shipped file**: `git hash-object packages/objectql/src/validation/record-validator.ts` is `2e2d3981d29a…`, byte-identical to the blob the ablation restored to, so nothing landed on the production file after it was proved able to fail. **Import-side pins**: the public surface of `@objectstack/objectql` is byte-unchanged (no export added, removed or retyped), so only behaviour could move a consumer pin. Every non-`objectql` test file mentioning `'percent'` was checked for a co-occurring `scale`; the six hits are `packages/spec` schema tests and two `service-analytics` wire tests, none of which exercises the record validator. The grep returning six files is its own lit control. ## Acceptance notes Noted, not filed — neither meets the three filing classes, and the carrier for each is named: - The `max_scale` message template (`packages/spec/src/system/validation-message.ts`) reads "must have at most N decimal places", which on a fraction-stored percent now describes the STORED fraction rather than the number the author typed. It is accurate and it is not what the author sees in the widget. Whether a percent-specific sentence is wanted is a display decision that belongs with the ruling's author, not a defect. Carrier: the contract review on this PR. - `packages/spec/src/data/numeric-column-representation.ts` already carries an accurate prose account of the fraction storage rule in its `percent` entry. It is documentation of the column, not of `scale`, and needs no change under this ruling. Carrier: none needed — recorded so the next reader does not re-derive the same dead end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18177
Clause-②: yes (narrowing)
Executes decision batch #146 item 4, letter A — maintainer 「146 同意」 2026-09-17T13:16Z.
BulkActionParamSchemabecomes strict like its twin and declares the key measured live on the surface; route B is not built, route C is not kept.Changeset carrier:
.changeset/18177-bulk-action-param-strict.md—@objectstack/specminor, body carrying 「Breaking for authored metadata」.ADR-0087 disposition:
registered ui-bulk-action-param-unknown-keys-refused— a D3 structured TODO, not a D2 conversion, for the reason the majors-15/16/17 strictness entries give: an arbitrary unknown key has no mapping target.1 — Measure first (the ruling's step 1)
1a. Which keys the bulk dialog reads off a bulk param after the spread
Instrument.
bulkParamToFielddestructures the eleven declared keys out and spreads the rest onto the field metadata handed togetLazyFieldWidget, so the question is: which keys does a widget read off that bag? Enumerated by scanning every form-widget modulegetLazyFieldWidgetcan return —objectui@3e4f6324f7,packages/fields/src/widgets/**(74 non-test modules) — for property reads off thefieldprop, following the local aliases those modules assign it (const config = field as any, and the chainedlookupField→fieldMeta→cascadeMetaunwrap inLookupField).Firing control (so a zero would be a reading). The positive control is
dependsOn: the scan returns it at 6 sites across 5 modules, and each was read by hand to confirm it is live code and not a comment. The negative probe (zzz_nonsense_key_that_no_producer_emits_8755) returns nothing.Instrument's reachable radius, and a known target outside it. The radius is
packages/fields/src/widgets/**in objectui. It does not reachpackages/fields/src/index.tsx, and a known target lives there:buildValidationRulesreadsfield.min/field.max/field.pattern/field.required_messageand more. That function is excluded deliberately, not by accident — it is the react-hook-form path the object FORM uses, and the bulk dialog does not go through it (BulkActionDialogrenders the widget directly). Its keys are therefore not evidence about this surface. A first pass of the scan that did includeindex.tsxalso over-reportedstartsWith(a string method, not a field key), which is why the alias-following pass was read by hand rather than trusted.Result —
dependsOnis live on BOTH widget families reachable from the dialog:SelectField,MultiSelectField,RadioField,CheckboxesFieldfield?.dependsOnuseCascadingOptionsLookupField, andUserFieldthrough itcascadeMeta?.dependsOnAnd a long tail of widget-config keys is read off the same bag —
min/max/step(NumberField, SliderField, CurrencyField, PercentField, RatingField),accept/maxSize/crop/capture(FileField, ImageField),rows(TextAreaField, RichTextField),precision/scale,dimensions(VectorField),defaultName(AvatarField), and the picker knobsdescriptionField/idField/allowCreate/lookupColumns/lookupPageSize/lookupFilters/picker/subtitle/avatarField(LookupField). ⭐formatis not among them, although the module header used to name it beside min/max/step: no form widget reads it. That discrimination is what makes the list a measurement rather than a transcription of the header.1b. Census of authored bulk params for keys the schema does not declare
Instrument. Bracket-matches every
bulkActionDefsarray in a tree, extracts eachparams[]object literal and lists its top-level keys. Firing control: a planted fixture carryingdependsOnand the nonsense key — the instrument reports both and leaves the eleven declared keys unflagged.objectstack-ai/objectstack@176b03582eobjectstack-ai/objectui@3e4f6324f7objectstack-ai/hotcrmInstrument's radius here too: it finds params written as object literals inside a
bulkActionDefsarray. A param assembled in a variable and spread in would be outside it. No such site was seen, but that is an absence the instrument cannot certify.⇒ No authored unknown key was found, so no ADR-0087 conversion entry is owed and there is no stop-and-report. The registered entry is the D3 structured TODO for the narrowing itself.
2 — What the change is
BulkActionParamSchemamoves fromz.object({…}).passthrough()tostrictObject({…}), and declaresdependsOn. The rejection is curated rather than bare:helpText→help,description→help,defaultValue→default,reference→object,referenceTo→object,displayField→labelField,title→label. These are the same three mappingstoBulkParamperforms when it promotes an ACTION param, so the authored and promoted directions now agree.field,objectOverride,visible,visibleWhen,carryOver,defaultFromRow,requiresFeature, each answered with the layer that really owns it. ⛔ None of them promises the field-backed route, because the bulk surface does not have one — that would be the confidently-wrong prescription this campaign has shipped before.BULK_PARAM_WIDGET_CONFIG_KEYS— one prescription for the whole measured widget-config family, namingFieldSchemaas the shape those keys are real on, and saying in as many words that declaring the key on the object's FIELD does not reach this dialog either.Why the declare set is
dependsOnand not the whole measured tailThe tail is measurably READ, so declaring it would be defensible on that half alone. It is not declared because the other half is missing: the census found no author writing one, and a declared key is published contract whose removal costs a full retirement kit, while an over-strict refusal costs one card. The asymmetry decides it. The measured tail is written into the file beside the guidanceSet so the next reader has the evidence without re-deriving it, and the residual question is filed rather than guessed — see Acceptance notes.
What is deliberately NOT closed
params[].options[]stays.passthrough(). Its openness rests on its own 2026-08-03 measurement (the option entries are spread verbatim into the field metadata, where the widgets readcolor/icon/disabled/visibleWhen), which this change does not disturb. Closing it by symmetry with its parent would delete widget config the renderer honours — the same defect this PR closes one level up. The declared{ label, value }pair is still type-checked.3 — A brief premise corrected on measurement
The dispatch named
packages/spec/src/ui/action.zod.tsas «the twin whose shape and.describe()text you must match». Measured:ActionParamSchemadeclares nodependsOnat all. The single-record dialog reaches the key through the field-backed route (resolveActionParamsresolves the object's field definitions), so the spec's only declaration of this key isFieldSchema.dependsOn(packages/spec/src/data/field.zod.ts) — which is also the spelling the card itself names as the one objectui was ruled to honour.So
action.zod.tsis the twin for strictness, andFieldSchemais the twin for this key's shape and description. Both halves are honoured: the member is byte-for-byte the field-level union (stringor a strict{ field, param }entry, same alias table), and the description is the field-level text with ONE sentence appended — a bulk run holds a selection and not a row, so «other field(s) on the same record» had to say what the record is here (the dialog's own in-progress param values, i.e. a sibling param of the same def). ⛔action.zod.tsis not edited.A parity pin (
accepts exactly what the FieldSchema twin accepts, and refuses exactly what it refuses, 8 cases, asserted equal as a vector and asserted to contain both verdicts) is what stops the two doors drifting into dialects.4 — Verification
Run against
837234d86b, this branch's final commit.pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec check:generatednode scripts/check-adr-0087-registration.mjs --base origin/main[BREAKING+clause-②-narrowing] registered ui-bulk-action-param-unknown-keys-refused (new here)pnpm lint(=eslint . --no-inline-config)grep -naPover all 14 changed paths, pluspnpm check:nul-bytesnode scripts/pm/dispatch-gates.mjsover the real change set, reconciled with--rancarrying exit codesThe 7 NOT MEASURED, every one a
PREREQUISITE NOT METrefusal that needs a repo-wide build this lane does not own (exit 3, except the last which exits 1 and says the same thing in words — recorded here rather than counted as a failure):check:doc-formula-expressions,check:doc-security-posture,check:docs-transcript-drift,check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt,check:skill-examples. ⛔ None of them read anything about this diff; they are declared to CI, not skipped quietly.Regenerated, never hand-edited:
authorable-surface/ui.json(gainsui/BulkActionParam:dependsOn),api-surface-declarations/*.txt,content/docs/references/ui/{bulk-action,view}.mdx, the strictness-ledger counts, andmigrations/registry.ts(from the new one-file entry, viagen:migration-registry). ⛔ Nothing was typed between the generated markers.authorable-surface.base.jsonis unchanged, as expected — onlygen:authorable-surface-basewrites it.Strictness ledger moves the right way:
ui/passthrough 3 → 2, strict 165 → 167; repo total strict 318 → 320, passthrough 4 → 3.No in-repo consumer of the narrowed type.
BulkActionParamloses its index signature when the shape closes. Measured: no file outsidepackages/spec/srcimports that type (packages/cliandpackages/spec/scriptsmention it in prose only), so no consumer typecheck is owed.packages/cli typecheckwas attempted and refuses on unbuilt workspace dependencies — the AGENTS.md section-9 stale-closure signature, unrelated to this diff and left to CI.Acceptance notes
Everything below was found on the way and is deliberately NOT fixed here.
min/max/step/precision/scale/rows/accept/maxSizeand the picker knobs are refused at parse while the widget one seam over would still honour them, and there is no field-backed route to reach the dialog by. That is a real authoring gap: an author reading the renderer's vocabulary writes a key the runtime now rejects. Sized and located by the measurement in §1a. Dedupe words:BulkActionParam, widget config,min/max/step,bulkParamToFieldspread, field-backed bulk param.objectui'spackages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsxleg B pins thatBulkActionParamSchemaACCEPTS a nonsense key, andpackages/types/src/__tests__/bulk-action-param-options.test.ts:139parses an authored param through the same schema. Both are correct against installed 17.4.0 and both turn red the day objectui's spec pin crosses this release; the first has to be re-judged into a refusal pin the way this PR re-judged its own. Nothing here breaks objectui's BUILD — no export is removed or renamed — so this is a coordination note, not a Post-Task-Checklist-4 blocker. Dedupe words: objectui,bulkLookupDependsOnReach-8755, leg B null reading, spec pin bump, bulk param strict.packages/spec/src/shared/union-author-message-pins.test.tscarries a hand-maintained table of string-or-object union sites, and this PR adds one (BulkActionParam.dependsOn). The file says out loud that nothing mechanically holds that table equal to the tree and that a standing re-scan «is deliberately left to its own card», so the gap is already recorded there. The new site's rendered message is pinned in this PR's own sibling test instead (surface phrase, rename arrow, and the string arm's kind mismatch asserted absent). Carrier: the next PR that touches that table, or the standing-guard card the file already names.min/max/step/formatwas 3-for-4 — no form widget readsformaton this path. The header is corrected in this PR rather than filed, because the sentence lives in the file being edited. Carrier: none needed.Generated by Claude Code