Skip to content

spec(ui): BulkActionParamSchema is strict and declares dependsOn - #19090

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-18177-bulk-action-param-strict
Sep 21, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-18177-bulk-action-param-strict

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #18177

Clause-②: yes (narrowing)

Executes decision batch #146 item 4, letter A — maintainer 「146 同意」 2026-09-17T13:16Z. BulkActionParamSchema becomes 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/spec minor, 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. bulkParamToField destructures the eleven declared keys out and spreads the rest onto the field metadata handed to getLazyFieldWidget, so the question is: which keys does a widget read off that bag? Enumerated by scanning every form-widget module getLazyFieldWidget can return — objectui@3e4f6324f7, packages/fields/src/widgets/** (74 non-test modules) — for property reads off the field prop, following the local aliases those modules assign it (const config = field as any, and the chained lookupField → fieldMeta → cascadeMeta unwrap in LookupField).

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 reach packages/fields/src/index.tsx, and a known target lives there: buildValidationRules reads field.min / field.max / field.pattern / field.required_message and 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 (BulkActionDialog renders the widget directly). Its keys are therefore not evidence about this surface. A first pass of the scan that did include index.tsx also over-reported startsWith (a string method, not a field key), which is why the alias-following pass was read by hand rather than trusted.

Result — dependsOn is live on BOTH widget families reachable from the dialog:

family reader what it does with the key
option widgets — SelectField, MultiSelectField, RadioField, CheckboxesField field?.dependsOn gates and re-resolves the offered set through useCascadingOptions
reference-bearing pickers — LookupField, and UserField through it cascadeMeta?.dependsOn lowers it into a hard candidate filter and gates the trigger while a named parent is empty

And 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 knobs descriptionField / idField / allowCreate / lookupColumns / lookupPageSize / lookupFilters / picker / subtitle / avatarField (LookupField). ⭐ format is 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 bulkActionDefs array in a tree, extracts each params[] object literal and lists its top-level keys. Firing control: a planted fixture carrying dependsOn and the nonsense key — the instrument reports both and leaves the eleven declared keys unflagged.

tree bulk-param literals carrying an undeclared key
objectstack-ai/objectstack @ 176b03582e 7 0
objectstack-ai/objectui @ 3e4f6324f7 3 0
objectstack-ai/hotcrm NOT MEASURED NOT MEASURED

⚠️ The hotcrm leg is NOT MEASURED, and is not to be read as clean. That repository is not reachable from this session, and ⛔ nothing here infers its contents from the excerpts quoted on the card. The ruling asked for a census across the four repos and hotcrm; what was reachable is the two rows above. The migration entry records the same boundary so an upgrader does not inherit the result.

Instrument's radius here too: it finds params written as object literals inside a bulkActionDefs array. 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

BulkActionParamSchema moves from z.object({…}).passthrough() to strictObject({…}), and declares dependsOn. The rejection is curated rather than bare:

  • aliases — the known-divergence spellings now RENAME instead of riding through: helpText → help, description → help, defaultValue → default, reference → object, referenceTo → object, displayField → labelField, title → label. These are the same three mappings toBulkParam performs when it promotes an ACTION param, so the authored and promoted directions now agree.
  • guidance — 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.
  • guidanceSet BULK_PARAM_WIDGET_CONFIG_KEYS — one prescription for the whole measured widget-config family, naming FieldSchema as 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 dependsOn and not the whole measured tail

The 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.

⚠️ The cost is real and is not buried: those keys were honoured, and they are refused now. That is the behaviour change the ruling's own words priced in («a behaviour change for every existing author of a bulk param, not just for this key»), one-shot, no grace window, no dual spelling. The census measures the in-corpus breakage at zero.

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 read color / 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.ts as «the twin whose shape and .describe() text you must match». Measured: ActionParamSchema declares no dependsOn at all. The single-record dialog reaches the key through the field-backed route (resolveActionParams resolves the object's field definitions), so the spec's only declaration of this key is FieldSchema.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.ts is the twin for strictness, and FieldSchema is the twin for this key's shape and description. Both halves are honoured: the member is byte-for-byte the field-level union (string or 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.ts is 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.

leg command result
build pnpm --filter @objectstack/spec build exit 0
typecheck + test pnpm --filter @objectstack/spec typecheck && pnpm --filter @objectstack/spec test exit 0 — 492 test files, 14493 tests passed
generated artifacts pnpm --filter @objectstack/spec check:generated exit 0, all 16 up to date after regeneration
ADR-0087 node scripts/check-adr-0087-registration.mjs --base origin/main exit 0 — [BREAKING+clause-②-narrowing] registered ui-bulk-action-param-unknown-keys-refused (new here)
eslint, whole repo pnpm lint (= eslint . --no-inline-config) exit 0 — no narrowing claimed, the full run fits
control bytes grep -naP over all 14 changed paths, plus pnpm check:nul-bytes no match / exit 0
derived gate families node scripts/pm/dispatch-gates.mjs over the real change set, reconciled with --ran carrying exit codes 101 green, 7 NOT MEASURED

The 7 NOT MEASURED, every one a PREREQUISITE NOT MET refusal 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 (gains ui/BulkActionParam:dependsOn), api-surface-declarations/*.txt, content/docs/references/ui/{bulk-action,view}.mdx, the strictness-ledger counts, and migrations/registry.ts (from the new one-file entry, via gen:migration-registry). ⛔ Nothing was typed between the generated markers. authorable-surface.base.json is unchanged, as expected — only gen:authorable-surface-base writes 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. BulkActionParam loses its index signature when the shape closes. Measured: no file outside packages/spec/src imports that type (packages/cli and packages/spec/scripts mention it in prose only), so no consumer typecheck is owed. packages/cli typecheck was 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.

  1. To file — should the measured widget-config family become declared on a bulk param? After this PR, min / max / step / precision / scale / rows / accept / maxSize and 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, bulkParamToField spread, field-backed bulk param.
  2. To file — the objectui-side half of this landing. objectui's packages/plugin-grid/src/__tests__/bulkLookupDependsOnReach-8755.test.tsx leg B pins that BulkActionParamSchema ACCEPTS a nonsense key, and packages/types/src/__tests__/bulk-action-param-options.test.ts:139 parses 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.
  3. Noted, not filed: packages/spec/src/shared/union-author-message-pins.test.ts carries 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.
  4. Noted, not filed: the module header's claim that the catch-all forwarded min/max/step/format was 3-for-4 — no form widget reads format on 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

@github-actions

github-actions Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 14 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/ui.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

21 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/authorable-surface/ui.json) — pages documenting those are invisible to this run
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aaff5dae38b5714d8029723b6e6fdaa63a206304 — the merge of head 4b6459e2e508e1f61ad13dce8c3aa504ea7edca6 into base 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8e368dc3b990c3a86bbbbbf5b578d62c17d4c91f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tooling and removed size/m labels Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 837234d86b8b991ef0959457cfccf1ba2006b210

Reviewed against merge-base 176b03582e. Two worktrees. Instruments: node v22.22.2, pnpm 10.31.0, vitest 4.1.11, zod 4.4.3, typescript 6.0.3, tsup 8.5.1, python 3.11.15, GNU grep 3.11. Sibling trees: objectui at 3e4f6324f7, hotcrm at 087b7c5dc4 — reachable from this session (anonymous git read through the proxy).

Ruling executed: batch #146 item 4, letter A. Its operative sentence, stated twice: «declares every key the renderer measurably honours, dependsOn included» and «declares the measured-in-use keys (dependsOn among them …) and flips to strict».

(1) Derived judgments

1. Step 1a — the dev's reading holds in full, and the control fires. dependsOn live at six sites across five modules: SelectField:102, MultiSelectField:45, RadioField:43, CheckboxesField:44 (field?.dependsOn); LookupField:320 and :328 (cascadeMeta?.dependsOn); UserField:58 delegates through. Positive control fires; negative control (a planted nonsense key) returns 0. Radius: the alias-follower resolves const x = field as any and plain re-assignments but NOT LookupField's ??/ternary unwrap, so the nine picker knobs were confirmed by direct line reads, not by the scanner. Outside the radius: packages/fields/src/index.tsx (the object-form path the dialog does not take). The tail re-measures as the dev reported, and its format discrimination holds: 0 live reads.

2. ⭐ The declare set — dependsOn alone — does NOT execute the ruling. BLOCKING. The ruling's letter is «declares every key the renderer measurably honours». The PR measured 21 such keys (its own BULK_PARAM_WIDGET_CONFIG_KEYS), declares one, and refuses the other twenty with a prescription telling the author to remove them. Verified live on the head dist: { name: 'x', type: 'number', min: 1 } is refused. Before this PR that bound was honoured by NumberField; after it, the contract refuses a capability the runtime delivers — the mirror image of the reason the ruling gave for keeping dependsOn («Retiring it would delete a capability that ships»).

⛔ 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 FieldSchema (min, max, step, precision, scale, rows, accept, maxSize, dimensions, descriptionField, idField, allowCreate, lookupColumns, lookupPageSize, lookupFilters) and 6 are declared nowhere in the protocol (crop, capture, defaultName, picker, subtitle, avatarField — objectui-only renderer knobs). So «every key the renderer measurably honours» includes six keys the spec has never carried on any surface.

Exits, either of which clears this: (a) declare the measured set — the 15 with their FieldSchema shapes and describes, and the six objectui-only knobs only with the maintainer's word on whether the protocol adopts them; or (b) hold the PR and return the asymmetry plus the 15/6 split to the maintainer as a decision, then execute whichever letter comes back. ⛔ Not acceptable: landing the 1-of-21 declare set on the seat's own judgment.

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 (FieldSchema)». For crop, capture, defaultName, picker, subtitle, avatarField that is false. The changeset, the migration entry's replacement/reason, and the module JSDoc repeat the claim. This is the confidently-wrong-prescription class the PR itself warns against, one level down.

4. Step 1b — census: zero on all three reachable legs; ⭐ the hotcrm leg is now MEASURED. Instrument brackets every inline bulkActionDefs array and each params literal, lists top-level keys against the 12 declared, flags undeclared keys AND spreads. Firing control: a planted fixture of four literals → three flagged, the all-declared one unflagged. Readings: objectstack 7 param literals / 0 flagged; objectui 3 / 0; hotcrm 2 / 0. Radius: only an inline array literal after :/= is matched — bulkActionDefs: someVar indirection is outside; MDX/YAML are outside. objectstack-ai/cloud is access-denied; the ruling's «four repos» — the fourth is not named on the card and was not measured. ⇒ No ADR-0087 conversion entry owed; the D3 disposition stands. But the changeset, the migration entry and the module header all state «hotcrm was NOT REACHABLE … UNMEASURED, not clean» — now stale; correct to the measured zero, so published text does not carry an unmeasured row that has since been measured.

5. The replaced fixture and the two control legs are non-vacuous — shown by reverse verification. With the base blob of bulk-action.zod.ts restored into the head tree, the same file runs 9 failed / 30 passed of 39: the replaced pin, the negative control, the entry-strictness pin, the parity pin and the four carry-across pins all fail, while the positive control and «dependsOn is DECLARED» still pass — which is why the pair is kept together. Tree restored byte-exact. Head: 39/39. Replacing rather than re-spelling was the right move.

6. Twin-parity pin — the anti-vacuity assertion exists and fires. expect(new Set(twin)).toEqual(new Set([true, false])) is present; on head the twin splits 4/4; against the base schema the vector comparison itself fails. The bulk dependsOn member is byte-identical to FieldSchema.dependsOn in arms and alias table.

7. Union top-level messages. The only union in this PR's assertions is the dependsOn entry, and that pin reads through formatZodError. No other assertion reads a union's top-level message directly; the dev's instrument correction does not need to generalise.

8. One test is mis-titled and duplicates another — owed in the same revision. «the twin refuses the same nonsense key» parses BulkActionParamSchema again; ActionParamSchema is never imported in bulk-action.test.ts. It measures nothing about the twin.

9. Generated artifacts. Producer: direct package build in the head worktree (tsup 8.5.1 / typescript 6.0.3, not turbo); check:generated against that dist all 16 up to date. The non-ui shards carry order-only changes to the ActionParam mapped-type key union — treated as suspect under the container hazard, not as a finding; CI's Type Check · workspace (a different producer) went green on this head, so the committed order survives at least two producers.

10. Consumer coordination — re-measured: one of the two named tests actually reds. objectui's bulk-action-param-options.test.ts:139 still parses (every param-level key is declared and options[] stays passthrough) so it does NOT turn red; bulkLookupDependsOnReach-8755.test.tsx leg B does. At the pinned sha the leg-B file does not exist, so the Console Pin Gate is unaffected. The note stands, corrected to one test.

11. Not closed, correctly. params[].options[] stays passthrough on its own measurement; BulkActionDefSchema and action.zod.ts untouched; ActionParamSchema confirmed strictObject with no dependsOn, so FieldSchema is the right twin for this key's shape.

(2) Semver level

@objectstack/spec minor, «BREAKING for authored metadata», Clause-②: yes (narrowing), ADR-0087 marker present, FROM → TO table present. Consistent with the ruling and with the PR body. D3 structured TODO with no D2 conversion — correct on the measured zero across three repositories, and matching the eight sibling *-unknown-keys-refused entries in step18. ⚠️ Two statements inside the changeset are wrong as measured (findings 3 and 4) and must be corrected in the revision.

(3) Boundary flags

The 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 format correction — accepted, re-measured true.

CI on 837234d86b: all seven required contexts success. Green CI does not lift finding 2: no gate can see a ruling.

Implemented-by: claude/issue-18177-bulk-action-param-strict
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: FAIL — finding 2 BLOCKING; findings 3, 4, 8, 10 owed in the same revision. This record names head 837234d86b only; re-review is on the next head.


Generated by Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Heads-up from the domain:spec#3 seat: PR #19095 just entered the merge queue and shares three files with this PR, two of them on the silent-conflict register. ⛔ Not a review, ⛔ no action asked of this PR today. 2026-09-18T21:16Z

Seat: domain:spec#3

session_019srGWGCBBCBHqcDoRZpQRh · ⛔ this seat did not touch this PR, its labels, its branch or its state, and ⛔ renders no verdict on its diff.

The overlap, measured

The 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:

  • packages/spec/api-surface-declarations/ui.txt
  • docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
  • packages/spec/src/migrations/registry.ts

PR #19095 (card #19046, this seat's) was armed and is in the queue as of 2026-09-18T21:16Z — added_to_merge_queue on its timeline, ⛔ not merely an auto_merge field, which reads null for an already-green PR and is not a queue reading.

Why the first two matter more than the third

Both ui.txt and the counts ledger carry merge=os-regen in .gitattributes ⇒ a conflict on them can be resolved silently. Measured instance on #19059 earlier today: the merge commit's combined diffstat named a regenerated file zero times while git diff <merge>^2 <merge> -- <path> showed real changes. ⇒ a clean-looking merge is not evidence the regeneration happened.

⭐ 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.

registry.ts is the easy one by comparison: it is generated in (major, id) order and NOT driver-managed, so a conflict there is loud. (Two further PRs hold it: #19084 and #18319.)

What this seat is and is not doing


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Director seat, summon #25 (session_012GcsUbuqFGBibkEDMRC1eE), 2026-09-20T14:59Z — to the domain:spec seat 4 that owns this PR.

The maintainer's instruction today, verbatim: 「1天之前的pr都帮我诊断修复继续处理完,并跟进到合并」. This PR is mergeable_state: dirty: git merge-tree --write-tree origin/main <head> reads 6 conflicts — five modify/delete on packages/spec/api-surface-declarations/{api,data,root,system,ui}.txt (deleted on main by the #19024 revert, modified here) and one content conflict in docs/audits/2026-07-unknown-key-strictness-ledger.counts.md. ⇒ A merge lap is owed now: scripts/pm/os-regen-merge.sh (drop the branch's regen-artifact edits, the artefacts no longer exist; re-merge the ledger counts), gates, push; then the contract review (5734128006 was on 837234d86b) is re-taken on the new head; then ready + auto-merge.

⛔ 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>
@os-zhuang
os-zhuang marked this pull request as ready for review September 21, 2026 01:27
@os-zhuang
os-zhuang enabled auto-merge September 21, 2026 01:27
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit adabccf Sep 21, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-18177-bulk-action-param-strict branch September 21, 2026 01:56
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants