Repository navigation
Commit e56112c
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 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 (#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. #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 #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>
1 parent c86d351 commit e56112c
5 files changed
Lines changed: 302 additions & 3 deletions
File tree
- .changeset
- content/docs/references/ui
- packages/spec/src
- ui
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
189 | 189 | | |
190 | 190 | | |
191 | 191 | | |
192 | | - | |
| 192 | + | |
193 | 193 | | |
194 | 194 | | |
195 | 195 | | |
| |||
354 | 354 | | |
355 | 355 | | |
356 | 356 | | |
357 | | - | |
| 357 | + | |
358 | 358 | | |
359 | 359 | | |
360 | 360 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
0 commit comments