Repository navigation
fix(spec): bound scale at the renderer ceiling of 100 (#18972) - #19083
Conversation
`FieldSchema.scale` and the inline grid column's `scale` were both declared as any non-negative integer with no upper bound, while every renderer of them turns the value into fraction digits through a primitive that refuses above 100: `Number.prototype.toFixed` (objectui's grid `computeRow`) and `Intl.NumberFormat`'s `maximumFractionDigits` (its number cell renderer) each throw `RangeError` at 101. A spec-valid declaration was therefore unrenderable, and its author got no signal at publish time — the failure arrived as a render crash in another repository. Both declarations now carry `.max(100)` with a refusal that says WHY: it names both primitives, the `RangeError`, and the legal maximum, so an author reads a platform limit they can verify rather than a cap somebody chose. The number is module-private (`MAX_RENDERABLE_SCALE`), so no published API surface moves. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
`gen:docs` picks up the two `scale` descriptions, which the field, object and migration reference tables all embed. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 6e941dd9543c84dfc127b2151da8db72ca49da31 && git checkout 6e941dd9543c84dfc127b2151da8db72ca49da31
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 176b03582e600ee5628d21bff9422073c5a5530c 2d62629edf61c38b38a1d6c8c9cfbc881adb1f21 && git checkout -B drift-repro 176b03582e600ee5628d21bff9422073c5a5530c && git merge --no-ff 2d62629edf61c38b38a1d6c8c9cfbc881adb1f21
node scripts/docs-audit/affected-docs.mjs --json 176b03582e600ee5628d21bff9422073c5a5530c
|
…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>
…bjectstack-ai#19088) (objectstack-ai#19313) Fixes objectstack-ai#19088 Clause-②: no (narrowing) `FormFieldBaseSchema.scale` in `packages/spec/src/ui/view.zod.ts` — the per-field override a form row carries — was declared as any non-negative integer with no upper bound. It is the third declaration of `scale` to reach the platform ceiling objectstack-ai#18972 bounded on the two in `data/field.zod.ts`: every consumer turns `scale` into decimal places, and both `Number.prototype.toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits` throw a `RangeError` at 101, so a conforming declaration could not render. The row now carries that bound, and the refusal names both primitives, the `RangeError` and the legal maximum rather than saying "too large". Evidence below is measured on this branch at `df3185cf9f`, base `origin/main` = `e6a03e649`, node v22.22.2. ## The card's premise, re-verified — one half of it is now false The dispatch's hardest fence rests on PR objectstack-ai#19083 being **open** and holding both `field.zod.ts` sites plus the module-private `MAX_RENDERABLE_SCALE`. Measured: **objectstack-ai#19083 merged at 2026-09-18T17:48:04Z** as `021755abc4`, two days before the dispatch was written, and both bounds plus the constant are on `main` today. The defect half of the premise is intact and was re-measured, not inherited: at the branch base `scale: 101` **parsed clean** on this row (bright control below). So the card stands; only the reason the fence gave for itself has expired. **The fence was obeyed anyway.** `packages/spec/src/data/field.zod.ts` is untouched in this diff — the file surface the claim declares is `view.zod.ts` and its tests, and widening it is not a call this seat makes even when the in-flight PR that motivated the fence has landed. The consequence is stated plainly in the arm measurement below: route 3 could not be completed on both ends this round, and what remains is recorded rather than smuggled in. ## The shared-constant half — three arms, measured | arm | what it costs | verdict | | :-- | :-- | :-- | | ① duplicate the literal in `view.zod.ts` | one platform fact recorded in a second place with nothing holding the two equal; a later consolidation still has to mint the shared home | viable, not taken | | ② promote `MAX_RENDERABLE_SCALE` to a published export | `packages/spec/src/data/index.ts:132` re-exports `field.zod` with `export *`, so the constant lands on the `@objectstack/spec/data` surface: `Clause-②: yes`, at least `minor`, an at-tier contract review — **and it edits `field.zod.ts`**, which the fence forbids | refused, on both counts | | ③ spec-internal, non-exported shared module | one new leaf file; **no cycle possible** (it imports nothing); **no published export** | **taken** | Arm ③'s two open questions, both answered by measurement rather than by reading: - **Can `view.zod.ts` import it without a cycle?** `packages/spec/src/shared/scale-ceiling.ts` has zero imports, so it is a graph leaf and cannot participate in one. `pnpm --filter @objectstack/spec build` exits 0 and `view.zod.ts` already imports eight other `../shared/*` modules. - **Does it move the published surface?** No. `check:api-surface`, `check:api-surface-declarations`, `check:export-origins` and `check:declaration-map` are all green with the file added — `shared/index.ts` is an explicit list of `export *` lines, not a wildcard, and this module is deliberately absent from it. The precedent is established: `strict-object.ts`, `editability-boundary.ts`, `union-branch-policy.ts` and `section-group-reference.ts` all live there unexported, and `SELECT_OPTION_EDITABILITY_GUIDANCE` has zero occurrences in `packages/spec/api-surface/`. **What arm ③ does NOT buy this round, stated rather than glossed.** Sharing has two ends, and the fence holds one of them: `field.zod.ts` keeps its own module-private `MAX_RENDERABLE_SCALE` and `SCALE_UPPER_BOUND_MESSAGE`, so the tree still records the number twice. The difference from arm ① is what the second copy is *for*: the new module is a home `field.zod.ts` can move into as a pure delete-and-import, buying no export and no review — and until it does, the two are held equal from this end by a pin that asserts the refusal text at both sites is **byte-identical**. A drift between the copies is a red test, not two divergent explanations of one platform limit. ## Acceptance — three legs **Bright control (before the change).** At the branch base `e6a03e649`, against the built `packages/spec/dist/ui/index.mjs`: ``` scale:100 : PASS scale:101 : PASS scale:-1 : REFUSED code=too_small message="Too small: expected number to be >=0" scale:2.5 : REFUSED code=invalid_type message="Invalid input: expected int, received number" precision:101: PASS ``` `scale: 101` passed, so this round demonstrably narrowed something and the probe was never dead. **Subject (after).** Same probe, same built artifact, rebuilt at `df3185cf9f`: ``` scale:100 : PASS scale:101 : REFUSED code=too_big message="Decimal places cannot exceed 100 — the limit is the renderers', not a policy: every consumer turns `scale` into fraction digits, and both `Number.prototype.toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits` throw a RangeError above 100, so a larger declaration is unrenderable rather than merely large. Declare at most 100 (an IEEE-754 double carries ~17 significant digits, so a meaningful display precision is far below it)." ``` `100` parses and round-trips; `101` is refused with a `too_big` issue at `[scale]` whose message names `toFixed`, `maximumFractionDigits`, `RangeError` and the legal maximum — the same text the two `field.zod.ts` sites produce, asserted as such rather than as prose. **Dark control.** `scale: -1` and `scale: 2.5` are refused before and after with the **same code and the same message bytes** (`too_small` / "Too small: expected number to be >=0"; `invalid_type` / "Invalid input: expected int, received number"). The objectstack-ai#8321 / objectstack-ai#12174 refusals are untouched; only the top of the range moved. The test asserts those two messages by equality, not by regex, so a restatement of the bottom of the range turns it red. **The platform fact re-measures every run, per the card.** `100` is never trusted as a literal: the pin probes `(1.5).toFixed(100 | 101)` and `new Intl.NumberFormat(undefined, { maximumFractionDigits: 100 | 101 })` for `RangeError` on every run and then asserts the schema's own accept/refuse boundary sits on the same number. If a future runtime moves either limit, the pin goes red and the declaration is re-argued. This is objectstack-ai#18972's shape, copied. ## The `check-widening-tells` warning the dispatch raised — measured, and it did NOT block The dispatch flagged objectstack-ai#19099: `check-widening-tells` T1 can refuse the criterion-honest `Clause-②: no (narrowing)`, making over-declaring the only unblocked path. Run against this branch's real diff: ``` node scripts/pm/check-widening-tells.mjs --declaration no --diff /tmp/pr.diff -> exit 0 ✓ check-widening-tells: 4 changed file(s) — 2 judged against a declared surface (no widening tell), 2 NOT MEASURED. ``` So this diff is a **negative sample** for objectstack-ai#19099, not another positive one. The two files judged against a declared surface are `view.zod.ts` and the new `shared/scale-ceiling.ts`; neither fired a tell. Worth recording for that card: T1 did **not** misfire on a `scale:` line that the diff re-emits in full to attach `.max(…)`, and T3 did not fire on a new file carrying two `export const`s that reach no entry point. The declaration stayed `no (narrowing)` on its merits; nothing was flipped to get unblocked. ## What this PR deliberately does not touch - **`packages/spec/src/data/field.zod.ts`** — the dispatch's fence, kept (see above). It is read-only input to one test assertion. - **`precision`** — untouched on this row, as on the object-field row. It is a TOTAL digit count and reaches neither primitive, so the renderer-ceiling argument does not carry to it; a pin records that the omission was measured rather than missed. Whether the three `precision` arms should agree is its own judgement. - **No renderer, no clamp, no fallback.** Refusing an unrenderable declaration at the door is this card; what a renderer should do when handed a large value stays objectui#9808's. - **objectui is not changed** — the consumer chain is cited from the schema's own existing comment, not re-measured across repositories this round. ## Verification | command | result | | :-- | :-- | | `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm --filter @objectstack/spec test` | exit 0 — 500 files, 14650 tests passed | | `pnpm --filter @objectstack/spec exec vitest run src/ui/view.test.ts` | exit 0 — 466 tests passed; the 7 new cases named individually under `--reporter=verbose` | | `pnpm --filter @objectstack/spec build` | exit 0 (base and head) | | `pnpm --filter @objectstack/spec check:generated` | exit 0 after `--fix` regenerated `content/docs/references/ui/view.mdx` (`gen:docs`) — the two `scale` descriptions, nothing else | | `pnpm lint` (whole repo, `eslint . --no-inline-config`) | exit 0 at `df3185cf9f` | | `pnpm check:nul-bytes` | exit 0 — 9028 text files, no raw control bytes | | `node scripts/check-adr-0087-registration.mjs --base origin/main` | exit 0 — 1 declared-breaking changeset, `not-required (no-migration-prescription)` | | `node scripts/check-changeset-no-major.mjs --base origin/main` | exit 0 | | `node scripts/pm/check-widening-tells.mjs --declaration no --diff …` | exit 0 | | gate families from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` | 106 derived, all 106 run, each exit code captured before any pipe; `--ran` reconciles to 0 UNRUN | **NOT MEASURED — seven of the derived commands, none of them a finding.** Each refuses its own prerequisite because it reads built `dist/` of packages this diff does not touch: `check:doc-formula-expressions`, `check:doc-security-posture`, `check:docs-transcript-drift`, `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` exit 3 with a PREREQUISITE NOT MET verdict; `check:skill-examples` refuses the same way but exits **1** (`packages/client-react/dist` holds no `.d.ts`), so `dispatch-gates --ran` classifies only six. Building that closure locally exceeded this container's foreground limit (`pnpm --filter '@objectstack/client-react...' build`, killed at 570s). They are recorded as NOT MEASURED, not as green; CI builds the closure and runs them for real. **Base.** This branch is not merged with `main` — it branched at `e6a03e649` and `main` has since moved. The intervening commits touch no `packages/spec/src` file (only the generated `packages/spec/liveness/*` ledger), so nothing above was measured against a file this branch also moves; the merge queue's rebuilt generation is the race-free joint check. ## Changeset `skip-changeset` does not hold and no such label is written: `@objectstack/spec` publishes `dist` in its `files[]` and `src/**/*.zod.ts` besides, and this diff changes what the published artifact accepts at runtime. The changeset is a `minor` carrying the **BREAKING** banner and the ADR-0087 disposition `not-required (no-migration-prescription)`, per the launch-window convention in which `major` is refused and breaking-ness rides the banner rather than the level. Its zero-hit reading: 126 `scale: N` declarations across `*.ts` / `*.tsx` / `*.json` / `*.mdx` / `*.yml` / `*.yaml` on this tree as the lit control, of which four read above 100 and all four are refusal fixtures or prose about them — zero authored declarations above the ceiling. Radius: this repository's tracked files; the known target outside it is a `sys_metadata` row already stored in a running deployment, which no in-repo instrument reaches (objectstack-ai#18972 recorded the same blind spot). ## Acceptance notes - **noted, not filed: the platform fact is still recorded three times in this tree.** `shared/scale-ceiling.ts` (new), `data/field.zod.ts`'s module-private copy, and `packages/objectql`'s `MAX_FORMULA_SCALE`. The first two are now pinned byte-equal from this side and the third cannot be folded in at all — `objectql` depends on `spec`, not the reverse, so the duplication there is structural. Successor for the first pair: whichever seat next holds `data/field.zod.ts`, where the adoption is a two-line delete-and-import against the module this PR lands and needs no export and no review. Successor for `MAX_FORMULA_SCALE`: none today. - **noted, not filed: `precision` is bounded on one arm and unbounded on two others.** `CurrencyConfigSchema.precision` is `.max(10)`; `FieldSchema.precision` and `FormFieldBaseSchema.precision` have no maximum (`precision: 101` is measured to parse clean on this row, before and after). No renderer-ceiling argument applies to any of them, so this is not the same defect wearing another name, and whether the three should agree is a judgement about three different field arms rather than a bug. objectstack-ai#18972 carried the identical note. Successor: none named — it is one line away from both sites, so whoever takes a `precision` decision takes all three. - **noted, not filed: the dispatch's fence outlived its stated reason.** PR objectstack-ai#19083 had already merged when the fence was written against it still being open. Recorded because the next card cut from the same seam will inherit the same sentence; the fence was still obeyed here. Successor: the PM loop that issues the next `field.zod.ts` card. --- _Generated by [Claude Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #18972
Clause-②: no (narrowing)
FieldSchema.scalewas declared as any non-negative integer with no upper bound, while everyrenderer that turns it into fraction digits hits a hard platform ceiling of 100. Both declarations
of
scaleinpackages/spec/src/data/field.zod.tsnow carry that ceiling, with a refusal that sayswhy — it names the two primitives, the
RangeErrorand the legal maximum — so an author reads aplatform limit they can verify rather than a cap somebody chose.
Evidence below is measured on this branch at
2d62629edf, baseorigin/main=176b03582e.The platform fact the bound rests on
There is no reusable decimal-digit ceiling constant in
packages/spec, so the number is newly mintedhere and its justification has to be the platform. Measured first-hand this round, node v22.22.2:
(1.5).toFixed(100)(1.5).toFixed(101)RangeError: toFixed() digits argument must be between 0 and 100new Intl.NumberFormat(undefined, { maximumFractionDigits: 100 })new Intl.NumberFormat(undefined, { maximumFractionDigits: 101 })RangeError: maximumFractionDigits value is out of range.The boundary is exactly 100, reproducing objectui#9808's reading rather than carrying it through. A
unit test re-measures both primitives on every run and asserts the schema's own accept/refuse
boundary sits on the same number, so the literal is never trusted on its own: if a future runtime
moves either limit, the pin goes red and the declaration is re-argued.
Corroboration from inside this repo:
packages/objectql/src/engine.ts:994already holdsconst MAX_FORMULA_SCALE = 100, whose docblock reads "FieldSchema.scaleisz.number().int().min(0)with no upper bound, so an author CAN declare more — and a display-precision declaration must never be
the reason a read fails." That is the consumer-side half of this same fact, written as a defence. This
PR is the producer-side half. The two are deliberately not one import:
packages/specmay not dependon a consumer package, and the new constant is module-private so no published API surface moves.
The dispatch's open question, answered: BOTH sites in this file are bounded
The file carries two
scaledeclarations. The card names onlyFieldSchema.scale; whether thegrid-column one reaches the same renderers was left to be measured. It does, by a different primitive,
so both are bounded:
InlineGridColumnSchema.scale(field.zod.ts:882at the base)computeRow,packages/fields/src/widgets/GridField.tsx:322—next[c.name] = scale != null ? Number(v.toFixed(scale)) : vtoFixedFieldSchema.scale(field.zod.ts:1158at the base)packages/fields/src/index.tsx:747—maximumFractionDigits: scale ?? 20Intl.NumberFormatThe link for the first row is not an inference:
InlineGridColumnSchema's own docblock declares it a"STRICT mirror of the objectui inline-grid renderer's
GridColumn(packages/fields/src/widgets/GridField.tsx)",and
GridColumn.scaleis the valuecomputeRowhands totoFixed. Measured in the sibling checkout../objectuiatdda8f3815d. Two declarations, two renderers, one ceiling.Acceptance legs
Subject.
scale: 100parses;scale: 101is refused at both sites with atoo_bigissue at[scale]whose message namestoFixed,maximumFractionDigits,RangeErrorand the legal maximum.Lit control (the same probe before the change). At
176b03582e, both sites answeredscale: 101with PASS — so this round demonstrably narrowed something, and the probe was never dead.
Dark control.
scale: -1andscale: 2.5were refused before and are refused after, for the samereasons, with the same codes and the same wording:
too_small/ "Too small: expected number to be >=0"and
invalid_type/ "Invalid input: expected int, received number". The #8321 refusals are untouched;only the top of the range moved.
Zero-hit reading, with its radius. Zero
scaledeclarations above 100 exist in this repository —git grepover all tracked files, 123scale: Ndeclarations as the lit control, 0 above 100. Theinstrument's reachable radius is this repository's tracked file contents at
2d62629edf. A knowntarget necessarily outside it: a
sys_metadatarow already stored in a running deployment's database,which no in-repo instrument can read. Triage recorded the same blind spot on this card.
What this PR deliberately does not touch
card; what a renderer should do when handed a large value is not, and stays objectui#9808's.
CurrencyConfigSchema's alias table is untouched. Itsscalemaps toprecisionon a differentsurface with its own bounds; the file's own comment says "do not conflate", and a pin asserts the
currency config still accepts
precision: 10.precisionis not bounded. It is a total digit count and reaches neither primitive, so therenderer-ceiling argument does not carry to it. A pin records that the omission was measured rather
than missed.
Verification
pnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec exec vitest run src/data/field.test.ts src/data/inline-related-columns.test.tspnpm --filter @objectstack/spec check:generated--fixregeneratedcontent/docs/references/**(gen:docs) — the twoscaledescriptions, nothing elsenode scripts/check-adr-0087-registration.mjs --base origin/mainnot-required (no-migration-prescription)node scripts/check-changeset-no-major.mjs --base origin/mainnode scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackSeven of the derived gate commands could not measure anything in this container: they read built
dist/of packages this diff does not touch (check:dual-build-cjs-loads,check:lean-entry-closure,check:docs-transcript-drift,check:type-check-debt, the two@objectstack/lintdoc gates, andcheck:skill-examples), and each exits with its own PREREQUISITE NOT MET verdict rather than afinding. They are recorded as NOT MEASURED, not as green; CI builds the closure and runs them for real.
Changeset
skip-changesetdoes not hold and no label is written:@objectstack/specpublishesdistin itsfiles[], and this diff changes what that published artifact accepts at runtime. The changeset is aminorcarrying the BREAKING banner and the ADR-0087 disposition, per the launch-windowconvention in which
majoris refused and breaking-ness rides the banner rather than the level.Acceptance notes
packages/spec/src/ui/view.zod.ts:2768(FormFieldBaseSchema.scale, the form-row override) carriesthe byte-identical unbounded declaration and reaches the same
Intl.NumberFormatrenderer — its ownneighbouring comment states that objectui reads every one of these keys off the form row, copied onto
the runtime field by
form-view.tsmapFieldand plugin-formsectionFields.ts. The card recordsit as a reading it did not fold into its claim, and the dispatch's declared file surface is
field.zod.tsonly, so widening this diff to a second file would move a surface the claim does notname — the instrument this repo uses to keep parallel agents off each other's files. It is reported
back for a decision rather than fixed silently; the fix is one line plus the shared constant, which
would have to be promoted out of
field.zod.tsat that point (a new export, so that PR declaresclause ②
yes).precisionis bounded on one arm and unbounded on two others.CurrencyConfigSchema.precisionis.max(10);FieldSchema.precisionandFormFieldBaseSchema.precisionhave no maximum. No renderer-ceiling argument applies to any of them,and whether the three should agree is a judgement about three different field arms. Successor: the
same seat that takes the
view.zod.tssite above, since it is one line away from both.MAX_FORMULA_SCALEand this file's bound now record one platform fact twice.Both are module-private and the dependency direction forbids sharing (
objectqldepends onspec,not the reverse), so the duplication is structural rather than an oversight. Successor: none today —
it would take a shared constants entry point on the published surface, which is a wider decision than
this card.
Generated by Claude Code