Repository navigation
fix(spec): bound the form-row scale at the renderer ceiling of 100 (#19088) - #19313
Conversation
`FormFieldBaseSchema.scale` in `packages/spec/src/ui/view.zod.ts` is the third site of the same platform ceiling: every consumer turns `scale` into decimal places, and both `Number.prototype.toFixed` and `Intl.NumberFormat`'s `maximumFractionDigits` throw a `RangeError` above 100, so a conforming declaration could not render. The constant is shared through a new `src/shared/scale-ceiling.ts` — a leaf module no package entry re-exports, so no published export moves. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. 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 a27b25f71a55d34db026e8666ca182194db00f56 && git checkout a27b25f71a55d34db026e8666ca182194db00f56
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4fef271b7199f6c5de5a5acd62ac420ac1a99ef0 3636ef2743f675dc099c1b558d4fd98f1b3784d2 && git checkout -B drift-repro 4fef271b7199f6c5de5a5acd62ac420ac1a99ef0 && git merge --no-ff 3636ef2743f675dc099c1b558d4fd98f1b3784d2
node scripts/docs-audit/affected-docs.mjs --json 4fef271b7199f6c5de5a5acd62ac420ac1a99ef0 |
Contract reviewServed-tier: 98/98 Tier reading: this reviewer's transcript harness stamps every request with Provenance of the head: ① Derived judgments
② Semver levelChangeset Level — right. A narrowing of a published accept set is BREAKING; under the launch-window convention ADR-0087 registration — right, and a migration prescription was genuinely not required. No authorable key is renamed, retired or reshaped, so ③ Boundary flags
Check runs on the head at the time of this record: 35 check runs at 2026-09-20T11:06Z — 31 success, 2 skipped, 0 failed, 2 still in progress ( Implemented-by: VERDICT: PASS Generated by Claude Code |
…ceiling The spec bridge (`form-view.ts` `mapField`) is retired at the `.objectui-sha` pin `53ded82bf7`. The route that carries the premise there, measured in the sibling checkout: `packages/plugin-form/src/sectionFields.ts:220` copies the form row's `scale` onto the runtime field, and `packages/fields/src/index.tsx` lines 661-667 hand it to `Intl.NumberFormat` as `maximumFractionDigits`. The changeset is the load-bearing one — its text ships to consumers inside `@objectstack/spec`'s CHANGELOG.md. The module docblock, the schema comment and the test docblock carry the same correction. Claude-Session: https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: 38/38 Delta review. The at-tier record at the earlier head Tier reading: this reviewer's transcript harness stamps every request with Provenance: ① Derived judgments
② Semver levelUnchanged by the delta, and re-checked rather than assumed. The changeset diff is lines 18–23 of the body (the route paragraph) and nothing else: with that paragraph removed from each, base and head are byte-identical — frontmatter ③ Boundary flagsAdopted from Re-derived here: the per-file prose-only proof and the executed equality of the constant and the message at both heads (①1); the corrected route at the pin, including the One low flag for the record: the earlier record's "434 bytes" for the refusal text is a character count (436 UTF-8 bytes; the em dash is three bytes). It changes nothing — the pin compares strings, not lengths — and is stated so the two records do not read as disagreeing. Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19088
Clause-②: no (narrowing)
FormFieldBaseSchema.scaleinpackages/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 ofscaleto reach the platform ceiling #18972 bounded on the two indata/field.zod.ts: every consumer turnsscaleinto decimal places, and bothNumber.prototype.toFixedandIntl.NumberFormat'smaximumFractionDigitsthrow aRangeErrorat 101, so a conforming declaration could not render. The row now carries that bound, and the refusal names both primitives, theRangeErrorand the legal maximum rather than saying "too large".Evidence below is measured on this branch at
df3185cf9f, baseorigin/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 #19083 being open and holding both
field.zod.tssites plus the module-privateMAX_RENDERABLE_SCALE. Measured: #19083 merged at 2026-09-18T17:48:04Z as021755abc4, two days before the dispatch was written, and both bounds plus the constant are onmaintoday.The defect half of the premise is intact and was re-measured, not inherited: at the branch base
scale: 101parsed 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.tsis untouched in this diff — the file surface the claim declares isview.zod.tsand 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
view.zod.tsMAX_RENDERABLE_SCALEto a published exportpackages/spec/src/data/index.ts:132re-exportsfield.zodwithexport *, so the constant lands on the@objectstack/spec/datasurface:Clause-②: yes, at leastminor, an at-tier contract review — and it editsfield.zod.ts, which the fence forbidsArm ③'s two open questions, both answered by measurement rather than by reading:
view.zod.tsimport it without a cycle?packages/spec/src/shared/scale-ceiling.tshas zero imports, so it is a graph leaf and cannot participate in one.pnpm --filter @objectstack/spec buildexits 0 andview.zod.tsalready imports eight other../shared/*modules.check:api-surface,check:api-surface-declarations,check:export-originsandcheck:declaration-mapare all green with the file added —shared/index.tsis an explicit list ofexport *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.tsandsection-group-reference.tsall live there unexported, andSELECT_OPTION_EDITABILITY_GUIDANCEhas zero occurrences inpackages/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.tskeeps its own module-privateMAX_RENDERABLE_SCALEandSCALE_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 homefield.zod.tscan 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 builtpackages/spec/dist/ui/index.mjs:scale: 101passed, so this round demonstrably narrowed something and the probe was never dead.Subject (after). Same probe, same built artifact, rebuilt at
df3185cf9f:100parses and round-trips;101is refused with atoo_bigissue at[scale]whose message namestoFixed,maximumFractionDigits,RangeErrorand the legal maximum — the same text the twofield.zod.tssites produce, asserted as such rather than as prose.Dark control.
scale: -1andscale: 2.5are 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 #8321 / #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.
100is never trusted as a literal: the pin probes(1.5).toFixed(100 | 101)andnew Intl.NumberFormat(undefined, { maximumFractionDigits: 100 | 101 })forRangeErroron 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 #18972's shape, copied.The
check-widening-tellswarning the dispatch raised — measured, and it did NOT blockThe dispatch flagged #19099:
check-widening-tellsT1 can refuse the criterion-honestClause-②: no (narrowing), making over-declaring the only unblocked path. Run against this branch's real diff:So this diff is a negative sample for #19099, not another positive one. The two files judged against a declared surface are
view.zod.tsand the newshared/scale-ceiling.ts; neither fired a tell. Worth recording for that card: T1 did not misfire on ascale:line that the diff re-emits in full to attach.max(…), and T3 did not fire on a new file carrying twoexport consts that reach no entry point. The declaration stayedno (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 threeprecisionarms should agree is its own judgement.Verification
pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec exec vitest run src/ui/view.test.ts--reporter=verbosepnpm --filter @objectstack/spec buildpnpm --filter @objectstack/spec check:generated--fixregeneratedcontent/docs/references/ui/view.mdx(gen:docs) — the twoscaledescriptions, nothing elsepnpm lint(whole repo,eslint . --no-inline-config)df3185cf9fpnpm check:nul-bytesnode 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/check-widening-tells.mjs --declaration no --diff …node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack--ranreconciles to 0 UNRUNNOT 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-closureandcheck:type-check-debtexit 3 with a PREREQUISITE NOT MET verdict;check:skill-examplesrefuses the same way but exits 1 (packages/client-react/distholds no.d.ts), sodispatch-gates --ranclassifies 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 ate6a03e649andmainhas since moved. The intervening commits touch nopackages/spec/srcfile (only the generatedpackages/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-changesetdoes not hold and no such label is written:@objectstack/specpublishesdistin itsfiles[]andsrc/**/*.zod.tsbesides, and this diff changes what the published artifact accepts at runtime. The changeset is aminorcarrying the BREAKING banner and the ADR-0087 dispositionnot-required (no-migration-prescription), per the launch-window convention in whichmajoris refused and breaking-ness rides the banner rather than the level. Its zero-hit reading: 126scale: Ndeclarations across*.ts/*.tsx/*.json/*.mdx/*.yml/*.yamlon 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 asys_metadatarow already stored in a running deployment, which no in-repo instrument reaches (#18972 recorded the same blind spot).Acceptance notes
shared/scale-ceiling.ts(new),data/field.zod.ts's module-private copy, andpackages/objectql'sMAX_FORMULA_SCALE. The first two are now pinned byte-equal from this side and the third cannot be folded in at all —objectqldepends onspec, not the reverse, so the duplication there is structural. Successor for the first pair: whichever seat next holdsdata/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 forMAX_FORMULA_SCALE: none today.precisionis bounded on one arm and unbounded on two others.CurrencyConfigSchema.precisionis.max(10);FieldSchema.precisionandFormFieldBaseSchema.precisionhave no maximum (precision: 101is 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. finding(spec):FieldSchema.scaledeclares no upper bound, but every renderer of it has a hard ceiling of 100 —toFixedandIntlboth throw RangeError at 101, so a spec-valid declaration is unrenderable #18972 carried the identical note. Successor: none named — it is one line away from both sites, so whoever takes aprecisiondecision takes all three.scaleat the renderer ceiling of 100 (#18972) #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 nextfield.zod.tscard.Generated by Claude Code