Skip to content

fix(spec): bound the form-row scale at the renderer ceiling of 100 (#19088) - #19313

Merged
os-steve merged 4 commits into
mainfrom
claude/issue-19088-form-field-scale-ceiling
Sep 20, 2026
Merged

os-steve merged 4 commits into
mainfrom
claude/issue-19088-form-field-scale-ceiling

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #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 #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 #19083 being open and holding both field.zod.ts sites plus the module-private MAX_RENDERABLE_SCALE. Measured: #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 #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. 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 #18972's shape, copied.

The check-widening-tells warning the dispatch raised — measured, and it did NOT block

The dispatch flagged #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 #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 consts 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 (#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. finding(spec): FieldSchema.scale declares no upper bound, but every renderer of it has a hard ceiling of 100 — toFixed and Intl both 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 a precision decision takes all three.
  • noted, not filed: the dispatch's fence outlived its stated reason. PR fix(spec): bound scale at 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 next field.zod.ts card.

Generated by Claude Code

`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>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:ui tests tooling labels Sep 20, 2026
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 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
  • 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 4fef271b7199f6c5de5a5acd62ac420ac1a99ef0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a27b25f71a55d34db026e8666ca182194db00f56 — the merge of head 3636ef2743f675dc099c1b558d4fd98f1b3784d2 into base 4fef271b7199f6c5de5a5acd62ac420ac1a99ef0, 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 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

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

os-steve commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 98/98 CONTRACT_REVIEW_TIER
Head-sha: df3185cf9fce167718393d1e2b79a33b60e7258d

Tier reading: this reviewer's transcript harness stamps every request with message.model; grepped over the reviewer's own transcript (subagents/agent-af5dd4ef657c29a32.jsonl, not the dispatching session's), 98 assistant records, 98 equal to the constant's value in scripts/pm/dispatch-gates.mjs. The value is not echoed here by rule. Isolated at-tier subagent; fed the card (#19088 body, report comment 5749323805), PR #19313 (body, diff, commits, comments, check runs) and the tree at the head; the dispatch order and the dispatching seat's conclusions were not read. Every dev claim below was re-measured, not inherited. Scratch: the reviewer's pr-19313/ scratchpad, with a detached worktree of the head built with pnpm --filter @objectstack/spec build (exit 0).

Provenance of the head: GET /pulls/19313 head = df3185cf9fce167718393d1e2b79a33b60e7258d on claude/issue-19088-form-field-scale-ceiling; REST compare e6a03e649...df3185cf9f = ahead 3 / behind 0 with merge base e6a03e649154252740b8e3534804e7abc453fb5c; main has moved 8 commits since, none touching packages/spec/src (only packages/spec/liveness/*, packages/lint, packages/metadata-protocol, two plugins, scripts/pm, ci.yml). Five files in the diff, as listed by the PR.

① Derived judgments

  1. The one accept-set movement — judged correct. FormFieldBaseSchema.scale (packages/spec/src/ui/view.zod.ts:2916) gains .max(100). Re-measured on the source tree with node v22.22.2, base and head: scale: 100 PASS both; scale: 101 PASS at base, too_big at [scale] at head. The number is the platform's, re-measured by me rather than read: (1.5).toFixed(101) and new Intl.NumberFormat(undefined, { maximumFractionDigits: 101 }) both throw RangeError on this runtime, 100 does not. Consumer route confirmed at the pinned objectui sha 53ded82bf7: packages/plugin-form/src/sectionFields.ts:220 copies scale onto the runtime field and packages/fields/src/index.tsx:661-667 hands it to maximumFractionDigits: scale ?? 20. Every value the bound now refuses was already a render-time crash; no conforming consumer loses a working input. Nit, comment-only: the second route the PR's comments, the module docblock and the changeset cite ("objectui's spec bridge, form-view.ts mapField") is not at the pin — objectui retired the spec bridge (.changeset/retire-spec-bridge-6366.md at 53ded82bf7, no spec-bridge/ tree, no mapField); the citation is inherited from liveness/view.json:325's 2026-08-26 measurement at objectui@f7c52e2, and the PR says it did not re-measure across repositories. The measured route carries the premise on its own; the stale name is worth dropping from the changeset before it ships in CHANGELOG.md, and it does not move this verdict.

  2. "No published export moves" — verified against the package, not the manifest alone; the claim holds. files[] ships dist, src/**/*.zod.ts and the tracked artifacts; the new file is src/shared/scale-ceiling.ts, not .zod.ts. npm pack --dry-run at the head: 293 entries, 15 src/shared/*.zod.ts present, scale-ceiling absent. The exports map is 19 fixed keys with no pattern, so no deep import reaches a non-entry file. tsup entries are the 17 index.ts files and shared/index.ts is an explicit export * list without the module. On my own build of the head: no dist/**/*scale-ceiling*, no .d.ts/.d.mts names MAX_RENDERABLE_SCALE, no bundle export list names it; the message text is inlined into every entry bundle that reaches view.zod/field.zod, which is how per-entry self-contained bundling already shipped field.zod.ts's copy. Gates on that build: check:api-surface unchanged, check:api-surface-declarations unchanged (17 entry points, 5364 declarations), check:export-origins current (5228 exports), check:declaration-map current — all exit 0. One consequence stated for the record: the tarball's src/ui/view.zod.ts now imports ../shared/scale-ceiling, which the tarball does not carry; the same file already imports six unshipped ../shared/*.ts siblings (strict-object, visibility, editability-boundary, section-group-reference, retired-key, lazy-schema), so the shipped .zod.ts set is reference text, not a compile unit, and this adds no new class. Mechanical floor for yes not tripped: no new export, no new key on a published payload. no (narrowing) is the criterion-honest declaration; check-widening-tells --declaration no re-run by me on the PR diff: exit 0, two files judged with no tell, two NOT MEASURED (changeset, mdx), the test file not a contract source — the dev's partition, reproduced.

  3. The pin — I tried to make it pass vacuously; it does not. The 7 new cases in src/ui/view.test.ts run under vitest project local (include: src/**/*.test.ts; 466/466 green on the file, the 7 named under --reporter=verbose), the file is reached by tsconfig.test.json (check:test-typecheck OK; the file's debt entry is byte-identical base to head). Five mutations in my worktree, each restored, control 7/7 green afterwards: M1 field.zod.ts message 100 to 99 at one site — byte-identity case red. M2 shared MAX_RENDERABLE_SCALE = 99 — 4 red (accepts-100, says-WHY /100/, platform-boundary, byte-identity). M3 view.zod.ts .max() removed — 4 red (too_big, says-WHY, platform-boundary, byte-identity). M4 one-word drift in the shared message — byte-identity red. M5 field.zod.ts loses its own .max() — byte-identity red (there.success flips true, asserted before the guard). Each if (!x.success) body is preceded by expect(x.success).toBe(false), so a passing parse cannot skip an assertion silently; hereMessage is toBeDefined() before equality, so a missing [scale] issue cannot equal undefined. M2 shows the platform fact is compared live on every run: the schema boundary is asserted against the primitives' own throw point, never against a trusted literal.

  4. Refusal wording — byte-equal. SCALE_UPPER_BOUND_MESSAGE in shared/scale-ceiling.ts derives its three 100s from the constant; data/field.zod.ts:849-854 hardcodes them. Evaluated both: 434 bytes, equal; the built probe returns the identical string from FormFieldSchema and FieldSchema at head. Dark control re-derived at base and head on the source tree: scale: -1 → too_small / "Too small: expected number to be ≥0" (source spells the sign as the two ASCII characters; written here with one glyph to keep angle brackets out of this comment); scale: 2.5 → invalid_type / "Invalid input: expected int, received number" — identical code and identical bytes before and after, on this row and on FieldSchema. precision: 101 parses before and after, pinned by a test; the card excludes precision by name.

  5. Coverage inside spec — complete. Every numeric scale declaration in packages/spec/src now carries the bound: data/field.zod.ts:931, :1213, ui/view.zod.ts:2916. ui/view.zod.ts:1264 is the timeline scale enum, a different key. No fourth site.

  6. Generated surfaces — current. content/docs/references/ui/view.mdx changes two rows, both renderings of FormFieldBaseSchema (## FormField Properties, and ### Nested Shape: FormSection.fields[number]). check:generated on my build: all 16 artifacts up to date, exit 0. json-schema/ is untracked build output. CI on the merge commit: Type Check · source gates (authorable-surface, docs, generated reconcile) success, Spec property liveness success. Nit: the .describe() embeds 0-100 and 100 as literals rather than deriving from the constant, the same shape as field.zod.ts's two describes from finding(spec): FieldSchema.scale declares no upper bound, but every renderer of it has a hard ceiling of 100 — toFixed and Intl both throw RangeError at 101, so a spec-valid declaration is unrenderable #18972; the constant cannot move without the platform pin going red, so the literal cannot drift silently.

② Semver level

Changeset .changeset/19088-form-field-scale-renderer-ceiling.md: '@objectstack/spec': minor, fix(spec)!: title, **BREAKING** banner, the narrowing declaration, ADR-0087 not-required (no-migration-prescription). Re-run by me against the branch point: check-adr-0087-registration --base e6a03e649… exit 0, [BREAKING+bang+clause-②-narrowing]; check-changeset-no-major exit 0 (level axis has no PR payload locally; CI Check Changeset is success on the head).

Level — right. A narrowing of a published accept set is BREAKING; under the launch-window convention check-changeset-no-major enforces, breaking ships as minor carrying the banner. patch would understate it, major is refused by the gate, and skip-changeset does not hold (@objectstack/spec publishes and the runtime accept set changes). Same level, banner and disposition as .changeset/18972-field-scale-renderer-ceiling.md, already in the tree for the two sibling sites.

ADR-0087 registration — right, and a migration prescription was genuinely not required. No authorable key is renamed, retired or reshaped, so objectstack migrate meta has no spelling to rewrite. The only mechanical FROM → TO would be a clamp to 100, which picks a display precision on the author's behalf — the lenient consumer fallback PD #12 forbids — and there is no working deployment to preserve, because no conforming renderer ever honoured a value above 100. The channel that reaches the author is the parse refusal, which carries the prescription (both primitives, the RangeError, the legal maximum). The category's own mechanical check (no FROM/TO block in the body) holds. The stored-sys_metadata blind spot is stated in the marker, not hidden, and is the same one #18972 recorded.

③ Boundary flags

  1. The dispatch fence's false premise — the dev's call was right; the outcome is acceptable and not this PR's to fold in. Re-measured: GET /pulls/19083 merged 2026-09-18T17:48:04Z as 021755abc4d1f915ee7a362a72f3945e4d4ed248; REST compare 021755abc4...e6a03e649 = ahead 91 / behind 0, so both field.zod.ts bounds and the private constant were on main at this branch's own branch point. The card's reading (17:15Z the same day) was true when taken; the dispatch restated it stale two days later. The claim's declared file surface (view.zod.ts plus its tests) is the coordination instrument between parallel seats and the seat that issued the fence owns its revision; widening the surface unilaterally — even for an expired reason — is the move the card itself names as the failure. The dev neither widened nor stayed silent: the expiry is measured and reported in the body, the report and on the card. The cost is one duplicated constant pair held byte-equal by a pin shown above to fail in both directions (M1, M4, M5); no contract, export or gate depends on which file declares the number.

  2. open_questions[0] — retire field.zod.ts's private copy? Answered: C, then A. Fold the two-line delete-and-import into whichever card next holds data/field.zod.ts; if nothing claims that file within the round, dispatch A as its own bounded card. Either way no published export moves (check:api-surface is unchanged because the module-private symbols were never on it) and no contract review is owed; the existing byte-identity pin keeps holding across the move. B is the honest floor meanwhile — the pin already carries the drift risk. Routed to the PM loop as the scheduling decision it is; no contract question stays open.

  3. noted, not filed, one by one.

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 (Test Core aggregate, Lint & Repo Gates). The landing seat reads the full set green before queueing; nothing in this review depends on the outstanding shards.

Implemented-by: claude/issue-19088-form-field-scale-ceiling
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

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>

os-steve commented Sep 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: 38/38 CONTRACT_REVIEW_TIER
Head-sha: 3636ef2743f675dc099c1b558d4fd98f1b3784d2

Delta review. The at-tier record at the earlier head df3185cf9fce167718393d1e2b79a33b60e7258d is comment 5749406773 (VERDICT: PASS). This record covers the ONE commit the head has moved by since — 3636ef2743f675dc099c1b558d4fd98f1b3784d2, «fix(spec): correct the objectui route cited for the form-row scale ceiling» — and adopts the rest of that record explicitly in ③ rather than carrying it silently.

Tier reading: this reviewer's transcript harness stamps every request with message.model; grepped over this reviewer's own transcript (subagents/agent-a24dd1dc54b8bcf11.jsonl — not the dispatching session's and not the earlier reviewer's), 38 assistant records, 38 equal to the value of CONTRACT_REVIEW_TIER at scripts/pm/dispatch-gates.mjs:11903. The value is not echoed here by rule. Isolated at-tier subagent; fed the earlier record, the delta commit, both heads and the objectui object store at the .objectui-sha pin; the dispatching seat's conclusions were not read. Scratch: pr-19313/delta-* in this session's scratchpad, with a detached worktree of the new head (delta-wt).

Provenance: GET /pulls/19313 head = 3636ef2743f675dc099c1b558d4fd98f1b3784d2 on claude/issue-19088-form-field-scale-ceiling, base main, open, 4 commits, 5 files. REST compare df3185cf9f...3636ef2743 = ahead 1 / behind 0, four files, numstat +6/−4, +17/−3, +9/−7, +10/−7 (42/21) — exactly the four the dispatch listed. .objectui-sha at the head = 53ded82bf7a494f54e344e19099dbf00854b8694. Check runs on the new head at the time of this record: 35 — 33 success, 2 skipped, 0 failed, 0 in progress (the two shards still running in the earlier record have completed on this head).

① Derived judgments

  1. The delta is prose-only — proven per file, not inferred from the line counts. For each of the three source files I filtered the base→head diff to its +/− lines and dropped the ones that begin with the comment marker ( * for the two docblocks, // for view.zod.ts): zero lines remain in all three. The one hunk in view.zod.ts (2903–2918) is the #19088 comment block above scale; the declaration itself — scale: z.number().int().min(0).max(MAX_RENDERABLE_SCALE, { message: SCALE_UPPER_BOUND_MESSAGE }).optional() and its .describe() — is a context line, unchanged, now at :2919 (was :2916; three comment lines were added above it). The one hunk in scale-ceiling.ts (26–48) lies inside the module docblock that opens at line 3 and closes at line 78; the two exports at :84 and :92–97 are untouched. Executed, not read: both versions of the module evaluated under node v22.22.2 (--experimental-strip-types; the module imports nothing) — MAX_RENDERABLE_SCALE is 100 at both heads, and SCALE_UPPER_BOUND_MESSAGE is string-equal at both heads, 434 characters / 436 UTF-8 bytes. data/field.zod.ts is not in the delta, so its hardcoded copy at :849–854 is the same one the earlier record compared, and the byte-identity leg is green at the new head (item 4). Nothing executable moved.

  2. The corrected citation is true at the pin — re-derived from the objectui object store at 53ded82bf7a494f54e344e19099dbf00854b8694, not from the earlier record. (The sibling checkout's HEAD is 3e4f6324; every read below is git show <pin>:….) packages/plugin-form/src/sectionFields.ts:220 is byte-for-byte if (fd.scale != null) base.scale = fd.scale;, in the run of constraint-key copies at :215–221 (maxLength, minLength, min, max, precision, scale, language). packages/fields/src/index.tsx:661–667 runs from const num = Number(safe); to maximumFractionDigits: scale ?? 20, inside formatDisplayNumber(num, { locale, scale, minimumFractionDigits: scale ?? 0, maximumFractionDigits: scale ?? 20 }), with scale read off numField.scale at :660. "Hands it straight to Intl.NumberFormat" needs one hop the citation does not name, so I followed it: formatDisplayNumber is imported from @object-ui/i18n (index.tsx:16) and defined in packages/core/src/utils/number-display.ts, which spreads the options into Intl.NumberFormatOptions (:174) and calls new Intl.NumberFormat(locale, intlOptions).format(value) (:202, :207). The route holds end to end. The retired route is retired: .changeset/retire-spec-bridge-6366.md is in the tree at the pin (@object-ui/react minor — "retire the spec-bridge — SpecBridge, bridgeListView, bridgeFormView (feat(spec)!: HierarchyScopeContext 增必填 posture 信号 —— single 姿态下 DEPTH 合法解析 (#6139) #6366)"); git ls-tree -r at the pin has no path matching spec-bridge other than that changeset and no file named form-view.ts anywhere; git grep mapField at the pin returns only mapFieldTypeToFormType — in app-shell source and tests, in changelog/ADR prose, imported from @object-ui/fields. Nit, comment-only: the new docblock says the hits are "mapFieldTypeToFormType in app-shell"; they also occur in .changeset/, docs/adr/0059 and two package CHANGELOGs, and the symbol's home is @object-ui/fields. The substantive claim — no bridge mapField exists at the pin — is exact.

  3. Where the stale name still lives, and whose it is. Across the full PR diff (e6a03e649…3636ef2743, 5 files, +302/−3), form-view.ts / mapField appear in added lines only inside the two "do not cite" warnings the delta adds (scale-ceiling.ts:37–40, view.zod.ts:2913–2915) — negative citations, correctly. The PR body has no hit; of the PR's comments only the earlier review names it. The positive citations that remain in the tree at the head pre-date this PR and sit outside the card's file surface: the spec/ui: FormField minLength/maxLength (and the number-constraint quartet) still carry the pre-#11566 shape — z.number().optional(), no integer/positivity validation #12174 blocks at view.zod.ts:2887 and view.test.ts:1716, and the spec-bridge/bridges/form-view.ts references at view.zod.ts:3360/:3377 (a different block, the spec.sections ?? renderer note). The delta names the first pair as inherited from liveness/view.json's 2026-08-26 reading and leaves them; that is the right call for a card fenced to one declaration. Noted here, not filed: the evidence is the retired changeset, and whoever next holds the spec/ui: FormField minLength/maxLength (and the number-constraint quartet) still carry the pre-#11566 shape — z.number().optional(), no integer/positivity validation #12174 block should drop the bridge half of its citation, ideally with the two spec-bridge/bridges/form-view.ts lines. Not a contract matter and not a verdict-mover.

  4. The tests still assert what they asserted, and the platform fact is still measured live. The one hunk in view.test.ts (1800–1815) is the describe-block docblock; no non-docblock line moved. At the new head, in the detached worktree: vitest run src/ui/view.test.ts --project local — 466/466, the seven #19088 cases named under --reporter=verbose: accepts-100, refuses-101 too_big at [scale], says-WHY (/toFixed/, /RangeError/), platform-boundary, SAME-text-as-object-field, spec: Field.scale accepts meaningless declarations (2.5, -1) — now that scale is enforced, malformed declarations should be refused at authoring time #8321/spec/ui: FormField minLength/maxLength (and the number-constraint quartet) still carry the pre-#11566 shape — z.number().optional(), no integer/positivity validation #12174 refusals unchanged, precision unbounded. The platform-boundary case (:1859–1865) executes (1.5).toFixed(100) / (101) and new Intl.NumberFormat(undefined, { maximumFractionDigits: 100 }) / 101 through a throwsRangeError probe on every run and asserts the schema's own boundary against those results; the byte-identity case (:1871–1880) parses FieldSchema with scale: 101 and compares the messages. Neither trusts the literal. The five mutations of 5749406773 ①3 are not re-run: nothing they exercise changed (item 1), and re-running them would be re-derivation, not delta.

② Semver level

Unchanged 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 '@objectstack/spec': minor, the fix(spec)!: title, Clause-②: no (narrowing), the **BREAKING** paragraph and the <!-- adr-0087: not-required (no-migration-prescription) … --> marker are the same bytes. Gates re-run from the worktree at the new head: check-adr-0087-registration --base e6a03e649… exit 0, [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription); check-changeset-no-major exit 0 (level axis has no PR payload locally; CI is green on the head); check-widening-tells --declaration no on the full PR diff exit 0, same partition as before (two contract sources judged with no tell, changeset and mdx not measured, the test file not a contract source). The level reasoning — a narrowing is BREAKING, breaking ships as minor + banner under the launch-window convention, patch understates, major is refused by the gate, skip-changeset does not hold — is ADOPTED from 5749406773 ②; the delta gives no reason to reopen it. The corrected paragraph is the text that ships in @objectstack/spec's CHANGELOG.md, which is why this commit exists: what consumers read now names a route that exists at the pin.

③ Boundary flags

Adopted from 5749406773, explicitly, because the delta does not reach them: ①2 (no published export moves — npm pack, the exports map, tsup entries, check:api-surface / check:api-surface-declarations / check:export-origins / check:declaration-map; no export, entry or files[] line is in this delta); ①3 (mutations M1–M5 and the expect(x.success).toBe(false)-before-guard shape; no assertion line moved); ①5 (three scale sites bounded, no fourth; no schema line moved); ①6 (generated surfaces current; the .describe() the mdx renders is a context line in this diff, so the two mdx rows cannot have drifted); ② (the level and the migration-prescription reasoning); ③1 (the dispatch fence's expired premise and the dev's handling of it), ③2 (open_questions[0]: C, then A), ③3 (the four noted, not filed items).

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 formatDisplayNumber hop, and the retirement of the bridge (①2); where the stale citation survives and whose it is (①3); the test run and the live re-measurement at the new head (①4); the changeset's untouched frontmatter, banner and marker and the three gate re-runs (②); provenance and the check-run state on the new head.

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: claude/issue-19088-form-field-scale-ceiling
Reviewed-by: session_01AmH9bKvGoLjiY86Q4Z3og2

VERDICT: PASS


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review September 20, 2026 13:03
@os-steve
os-steve added this pull request to the merge queue Sep 20, 2026
Merged via the queue into main with commit e56112c Sep 20, 2026
37 checks passed
@os-steve
os-steve deleted the claude/issue-19088-form-field-scale-ceiling branch September 20, 2026 13:48
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/m tests tooling

Projects

None yet

2 participants