Skip to content

fix(spec): bound scale at the renderer ceiling of 100 (#18972) - #19083

Merged
os-bill merged 2 commits into
mainfrom
claude/issue-18972-field-scale-upper-bound
Sep 18, 2026
Merged

os-bill merged 2 commits into
mainfrom
claude/issue-18972-field-scale-upper-bound

Conversation

@os-bill

@os-bill os-bill commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Fixes #18972

Clause-②: no (narrowing)

FieldSchema.scale was declared as any non-negative integer with no upper bound, while every
renderer that turns it into fraction digits hits a hard platform ceiling of 100. Both declarations
of scale in packages/spec/src/data/field.zod.ts now carry that ceiling, with a refusal that says
why — it names the two primitives, the RangeError and the legal maximum — so an author reads a
platform limit they can verify rather than a cap somebody chose.

Evidence below is measured on this branch at 2d62629edf, base origin/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 minted
here and its justification has to be the platform. Measured first-hand this round, node v22.22.2:

probe reading
(1.5).toFixed(100) ok
(1.5).toFixed(101) RangeError: toFixed() digits argument must be between 0 and 100
new Intl.NumberFormat(undefined, { maximumFractionDigits: 100 }) ok
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:994 already holds
const MAX_FORMULA_SCALE = 100, whose docblock reads "FieldSchema.scale is z.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/spec may not depend
on 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 scale declarations. The card names only FieldSchema.scale; whether the
grid-column one reaches the same renderers was left to be measured. It does, by a different primitive,
so both are bounded:

declaration the reader it reaches primitive
InlineGridColumnSchema.scale (field.zod.ts:882 at the base) objectui computeRow, packages/fields/src/widgets/GridField.tsx:322 — next[c.name] = scale != null ? Number(v.toFixed(scale)) : v toFixed
FieldSchema.scale (field.zod.ts:1158 at the base) objectui number cell renderer, packages/fields/src/index.tsx:747 — maximumFractionDigits: scale ?? 20 Intl.NumberFormat

The 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.scale is the value computeRow hands to toFixed. Measured in the sibling checkout
../objectui at dda8f3815d. Two declarations, two renderers, one ceiling.

Acceptance legs

Subject. scale: 100 parses; scale: 101 is refused at both sites with a too_big issue at
[scale] whose message names toFixed, maximumFractionDigits, RangeError and the legal maximum.

Lit control (the same probe before the change). At 176b03582e, both sites answered scale: 101
with PASS — so this round demonstrably narrowed something, and the probe was never dead.

Dark control. scale: -1 and scale: 2.5 were refused before and are refused after, for the same
reasons, 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 scale declarations above 100 exist in this repository —
git grep over all tracked files, 123 scale: N declarations as the lit control, 0 above 100. The
instrument's reachable radius is this repository's tracked file contents at 2d62629edf. A known
target necessarily outside it: a sys_metadata row 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

  • 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 is not, and stays objectui#9808's.
  • CurrencyConfigSchema's alias table is untouched. Its scale maps to precision on a different
    surface with its own bounds; the file's own comment says "do not conflate", and a pin asserts the
    currency config still accepts precision: 10.
  • precision is not bounded. 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.
  • objectui is not changed — read only, for the two renderer readings above.

Verification

command result
pnpm --filter @objectstack/spec build exit 0
pnpm --filter @objectstack/spec test exit 0 — 492 files, 14490 tests passed
pnpm --filter @objectstack/spec exec vitest run src/data/field.test.ts src/data/inline-related-columns.test.ts exit 0 — 284 tests passed
pnpm --filter @objectstack/spec check:generated exit 0 after --fix regenerated content/docs/references/** (gen:docs) — the two scale descriptions, nothing else
node scripts/check-adr-0087-registration.mjs --base origin/main exit 0 — one declared-breaking changeset, disposition not-required (no-migration-prescription)
node scripts/check-changeset-no-major.mjs --base origin/main exit 0
gate families from node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack every command run, exit code captured before any pipe; see the report for the NOT MEASURED residue

Seven 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/lint doc gates, and
check:skill-examples), and each exits with its own PREREQUISITE NOT MET verdict rather than a
finding. They are recorded as NOT MEASURED, not as green; CI builds the closure and runs them for real.

Changeset

skip-changeset does not hold and no label is written: @objectstack/spec publishes dist in its
files[], and this diff changes what that published artifact accepts at runtime. The changeset is a
minor carrying the BREAKING banner and the ADR-0087 disposition, per the launch-window
convention in which major is refused and breaking-ness rides the banner rather than the level.

Acceptance notes

  • A third site, measured and deliberately left out of this diff.
    packages/spec/src/ui/view.zod.ts:2768 (FormFieldBaseSchema.scale, the form-row override) carries
    the byte-identical unbounded declaration and reaches the same Intl.NumberFormat renderer — its own
    neighbouring comment states that objectui reads every one of these keys off the form row, copied onto
    the runtime field by form-view.ts mapField and plugin-form sectionFields.ts. The card records
    it as a reading it did not fold into its claim, and the dispatch's declared file surface is
    field.zod.ts only, so widening this diff to a second file would move a surface the claim does not
    name — 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.ts at that point (a new export, so that PR declares
    clause ② yes).
  • 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. 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.ts site above, since it is one line away from both.
  • noted, not filed: MAX_FORMULA_SCALE and this file's bound now record one platform fact twice.
    Both are module-private and the dependency direction forbids sharing (objectql depends on spec,
    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

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 4 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-driven.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/external-datasources.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/field-types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/data-modeling/validation-rules.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/troubleshooting.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/deployment/validating-metadata.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/getting-started/quick-reference.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/kernel/contracts/data-engine.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/backward-compatibility.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectql/types.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/protocol/objectui/concept.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/ui/forms.mdx (via FieldSchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-0.mdx (via FieldSchema (symbol, a top-level const))
  • content/docs/releases/v17/17-1.mdx (via FieldSchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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.

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 176b03582e600ee5628d21bff9422073c5a5530c → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 6e941dd9543c84dfc127b2151da8db72ca49da31 — the merge of head 2d62629edf61c38b38a1d6c8c9cfbc881adb1f21 into base 176b03582e600ee5628d21bff9422073c5a5530c, 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 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

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

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

@os-bill
os-bill marked this pull request as ready for review September 18, 2026 17:20
@os-bill
os-bill added this pull request to the merge queue Sep 18, 2026
Merged via the queue into main with commit 021755a Sep 18, 2026
37 checks passed
@os-bill
os-bill deleted the claude/issue-18972-field-scale-upper-bound branch September 18, 2026 17:48
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…w arm rules (objectstack-ai#19046) (objectstack-ai#19095)

Fixes objectstack-ai#19046

Clause-②: yes

The `object-grid` page-component door declared `pagination: z.unknown()`
and `pageSize: z.number()`, so the same authored member carried **two
accept sets** and renderers read the looser one. This bounds the
page-size members to the accept set the view arm has ruled all along,
and deliberately leaves the `pagination` bag open.

## The premise, re-derived by symbol at this branch's base (`362035cc0`)

⛔ No line number inherited from the card — triage warned about exactly
that, and the card's own reading was taken on `abb01f1`.

| arm | symbol | declaration at my base | accepts `0`? |
|:--|:--|:--|:--|
| view | `PaginationConfigSchema`
(`packages/spec/src/ui/view.zod.ts:867-868`) | `pageSize:
z.number().int().positive().default(25)` · `pageSizeOptions:
z.array(z.number().int().positive()).optional()` | no |
| grid component | `ObjectGridPropsSchema`
(`packages/spec/src/ui/component.zod.ts:2632`, `:2634`) | `pagination:
z.unknown().optional()` · `pageSize: z.number().optional()` | **yes —
both** |

The view arm's refusals are pinned **by name** (`view.test.ts` — `should
reject negative pageSize`, `should reject zero pageSize`, and the same
pair for `pageSizeOptions`). The corpus corroboration also holds at my
base — every other page-size declaration in the package is bounded:

```
packages/spec/src/ui/view.zod.ts:867           z.number().int().positive().default(25)
packages/spec/src/ui/view.zod.ts:868           z.array(z.number().int().positive())
packages/spec/src/ui/component.zod.ts:2634     z.number()                               ** the outlier
packages/spec/src/marketplace/marketplace.zod.ts:435   z.number().int().min(1).max(100).default(20)
packages/spec/src/marketplace/marketplace.zod.ts:456   z.number().int().min(1)
packages/spec/src/kernel/metadata-plugin.zod.ts:399    z.number().int().min(1).max(500).default(50)
packages/spec/src/kernel/metadata-plugin.zod.ts:429    z.number().int().min(1)
```

PR objectstack-ai#18638, which held this file, is merged (2026-09-18T16:01:37Z) and
did **not** tighten it in passing, so triage's downgrade clause does not
apply.

## The shape decision — a permissive object, and the evidence that chose
it

The card's complaint is that the two arms disagree about a page
**size**. It is ⛔ not that `pagination` should become a closed shape.
Two shapes were plausible; the evidence is one-sided.

**Chosen: `z.looseObject({ pageSize, pageSizeOptions })`** — validates
the two declared members, passes every other key through.

**Rejected: `z.unknown()` plus a refinement judging only `pageSize`.**
It looks more conservative and is measurably worse here:

- `z.toJSONSchema()` has **no arm for a `custom` check**. A record, the
same record with a `.refine()`, and the same record with an aborting
`.refine()` all project byte-identically — the mechanism
`packages/spec/dropped-refinements.baseline.json` exists to record. A
refinement would have left the **published** JSON Schema still accepting
`pageSize: 0` while the parser refused it, and it would have needed a
**new row in that shrink-only ledger**, which is a ratchet this dev may
not raise.
- The loose object is a **type** narrowing, so it projects. Measured on
the built artifact:

```
packages/spec/json-schema/ui/ObjectGridProps.json
  pagination.properties.pageSize                 { "type": "integer", "exclusiveMinimum": 0 }
  pagination.properties.pageSizeOptions.items    { "type": "integer", "exclusiveMinimum": 0 }
  pagination.additionalProperties                {}          ** the bag stays OPEN
  pageSize                                       { "type": "integer", "exclusiveMinimum": 0 }
```

`dropped-refinements.baseline.json` is **untouched** by this PR:
`ui/ObjectGridProps` keeps its single pre-existing `filter.element` site
and gains none.

**Read points, measured at objectui `d18322415`** (the sibling checkout
in this container; the `.objectui-sha` pin is `53ded82bf`):
`ObjectGrid.tsx:1209` and `:1628` read `(schema.pagination as
any)?.pageSize ?? schema.pageSize`, `:4179` reads
`schema.pagination?.pageSize`, `:4359` reads
`schema.pagination?.pageSizeOptions`. Across objectui's whole source,
`pageSize` and `pageSizeOptions` are the **only two members** any
`pagination` read point names (37 + 6 reads of `.pageSize`, 7 + 3 of
`.pageSizeOptions`, zero of anything else). The objectui registry
declares this input `type: 'object'` (`plugin-grid/src/index.tsx:223`).

### What was NOT narrowed, and why

- **Sibling keys inside the bag.** `z.looseObject`, not `strictObject`:
a sibling key that parsed before still parses **and still survives the
parse byte-identically**. Reusing `PaginationConfigSchema` here would
have refused every one of them — the `…` in this door's own describe
says authors write them — which is a wider breaking change than the
card's premise and a different decision. §3 of the new pin is what makes
that auditable; §4 records the deliberate asymmetry (the view arm stays
closed, this bag stays open), so a future author harmonising the two
arms reds a case instead of discovering the consequence in a renderer.
- **No `.default(25)` added to the flat shorthand.** The view arm has
one; adding one here would change parsed output, not the accept set.
- **`pageSizeOptions` WAS bounded, and that is a judgement I am naming
rather than burying.** It is the same defect class by a second door:
`pageSizeOptions: [0, 25]` puts a zero entry in the page-size selector,
which sets the fetch window to zero rows — the card's exact failure. Its
shape was already pinned by the view arm
(`z.array(z.number().int().positive())`), whose zero/negative refusals
are pinned by name, and its read point is measured above. Corpus cost:
zero `pageSizeOptions` entries outside the spec's own refusal fixtures
are non-positive.

### One second axis, stated rather than left to be discovered

`pagination` moves from `z.unknown()` to an object type, so a non-object
value (`pagination: true`) is refused where it used to parse. Measured
before narrowing:

- **zero** non-object `pagination` values on an `object-grid` node in
either repository (the `pagination: false` hits in objectui are on
`data-table` / `object-data-table`, whose props this schema does not
declare, plus one internal per-group table the grid builds itself at
`ObjectGrid.tsx:4590`);
- the registry has published `type: 'object'` all along, so the html
tier already answered `type-mismatch` on one while this schema accepted
it — the same shape the `sort` docblock two members up already records;
- `ObjectGrid.tsx:4175` reads the key for **presence**
(`schema.pagination !== undefined ? true : …`), which means an authored
`pagination: false` used to turn paging **ON**. That value now gets a
located refusal instead of the opposite of what it says.

## Pins, each with its control

New file:
`packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts`
— 19 cases, 4 sections.

| section | asserts | control |
|:--|:--|:--|
| §1 | `pagination.pageSize` refuses zero / negative / non-integer, and
`pageSizeOptions` entries refuse zero / negative — each asserting the
issue **code and path** (`too_small` at `pagination.pageSize`), not a
bare throw | two LIT CONTROLS: a legal `pageSize` parses and is
preserved; the whole ruled bag parses with its options |
| §2 | the flat shorthand carries the same accept set, by name | a LIT
CONTROL: `pageSize: 25` parses and keeps its value |
| §3 | a sibling key in the bag parses with **no `unrecognized_keys`
issue**, survives byte-identically (`toStrictEqual`), and a bag of only
sibling keys parses | this section IS the control for the trap above |
| §4 | both arms refuse the same three non-page-sizes, and both accept
`50` | an unknown KEY is refused by the view arm (`unrecognized_keys`)
and accepted by the component bag — the asymmetry, pinned |

**Defect reproduced in this tree, then the refusal proved able to
fail.** Ablation through `scripts/ablation-replace.mjs`, anchor `const
GridPageSizeSchema = z.number().int().positive();` replaced by `const
GridPageSizeSchema = z.number();` (the pre-PR accept set), from the
committed state:

```
ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4dec -> 462c333a1bda
  Test Files  1 failed (1)
       Tests  11 failed | 8 passed (19)
  FAIL §1 ... > should reject zero pageSize
  AssertionError: expected true to be false     ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS
ablation-replace: ok restored: blob == HEAD (d9e4dec) and `git diff HEAD` is empty
```

The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed
green are the lit controls and §3's openness pins — the right partition,
since the ablation removed only the value bound. Restored again through
the explicit form: `git checkout HEAD --
packages/spec/src/ui/component.zod.ts`, then `git hash-object` equal to
`git rev-parse HEAD:` that path (`d9e4decd6443…`), `git diff HEAD` empty
and `git status --porcelain` empty — and the pin re-run green (19/19)
from the restored tree.

## Changeset — the derivation, quoting the rule

`.changeset/19046-object-grid-page-size-accept-set.md` grades
`@objectstack/spec: minor`, carries the BREAKING banner, `Clause-②: yes
(narrowing)`, a FROM → TO table and the ADR-0087 disposition.

- `scripts/check-changeset-no-major.mjs` header: **"During the launch
window we ship breaking changes as `minor`"**, and its end condition —
**"at GA … an accept-set narrowing … grades `major`. Until then it is
NOT the carrier"** — with `major` refused outright by the guard. So the
rule does ⛔ not point at `major`, and there is nothing here for the
maintainer floor to rule on.
- `pr-automation.yml` "WHICH LEVEL": a widening takes at least `minor`,
and the level axis refuses `patch` across the board on a PR that
declares clause ②. Declaring `Clause-②: yes` therefore forces at least
`minor` — which is where the launch-window rule already put it.
- Direction carriers, per the same header: the **BREAKING banner** plus
the **ADR-0087 disposition**. Disposition is `registered
ui-object-grid-page-size-positive-integer-refused`, a new semantic entry
— the four `not-required` categories are all refused by construction
here (`unpublished`: spec publishes; `no-migration-prescription` and
`runtime-interface-only`: the body carries a FROM → TO table, and "a
changeset that ships instructions for rewriting a consumer's code cannot
also claim that no consumer has to rewrite anything";
`type-surface-only`: this is a runtime accept set on a metadata surface,
not a type annotation).
- `skip-changeset` was never available: this moves a published accept
set on a package that ships.

Verdicts: `check-changeset-no-major.mjs` exit **0**;
`check-adr-0087-registration.mjs` exit **0** — `1 declared-breaking
changeset(s), each carrying an ADR-0087 disposition`.

## Verification

Full census derived from the real change set after the changeset
existed, at `8ecc9b6ed`, with every exit code captured **before** any
pipe:

```
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  -> 8 path(s) vs merge base 07c6f82, three-dot; 109 commands
  107 exit 0  ·  2 PREREQUISITE NOT MET (exit 3)  ·  0 findings
```

The two that could not run, neither a pass nor a finding:

| family | reason | what it needs |
|:--|:--|:--|
| `pnpm check:dual-build-cjs-loads` | `PREREQUISITE NOT MET — this gate
reads built output, and some package has no dist/` (34 packages) | a
repo-wide `pnpm build`; CI's `Build Core` supplies it |
| `pnpm check:type-check-debt` | `--re-measure cannot run: 1 workspace
dependenc(ies) … have no built type entry point on disk —
@objectstack/driver-turso` | `turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, as lint.yml does |

Four families reported `PREREQUISITE NOT MET` or a missing input on
first run and were then **made to run** rather than declared:
`check:doc-formula-expressions` and `check:doc-security-posture` (needed
`@objectstack/formula` + `@objectstack/lint` built) and
`check:skill-examples` (needed `@objectstack/client-react`'s closure)
all became exit 0; `check:react-declaration-parity` was run as CI runs
it (`MANIFEST="$PWD/sdui.manifest.json" … --strict`) and reports **no
new declaration divergence vs the accepted baseline**.

Beyond the census:

- `pnpm --filter @objectstack/spec test` — **495 files / 14539 tests
pass** (post-merge); `typecheck` green, test-layer ledger unmoved at 54
files / 259 errors / 144 pinned signatures.
- `pnpm --filter @objectstack/spec check:generated` — **all 16 generated
artifacts up to date**. Three were proved stale and regenerated with
`--fix` only (`api-surface-declarations/`, `content/docs/references/**`,
the strictness-ledger counts); the `authorable-surface.base.json` anchor
was never touched.
- The one in-repo consumer of the changed surface is `packages/lint`
(`ComponentPropsMap`, `@objectstack/spec/ui`): `typecheck` green with
its ledger unmoved (2 files / 6 errors / 2 pinned), `test` **104 files /
3910 tests pass**. No corpus fixture anywhere in `examples/`, `apps/` or
another package authors `pagination` on an `object-grid` node, so
nothing in the tree newly fails to parse.
- Repo-wide `pnpm lint` (`eslint . --no-inline-config`) — exit **0**,
whole tree, no narrowing claimed.
- `pnpm check:nul-bytes` exit 0, plus a direct control-character scan
over all 8 changed paths — clean.
- Merged `origin/main` through `scripts/pm/os-regen-merge.sh` (its step
2 took main's side of `api-surface-declarations/ui.txt`, which both
sides moved, and step 3's hook held the regeneration debt until it was
discharged). This branch's delta against `origin/main` on that shard is
now **exactly the two `pagination` hunks**, with main's own advance
intact.

### The widening-tells reading, with its caveat

```
node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF   -> exit 0
✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already
  routes to contract review, so a tell on top of it decides nothing.
```

⚠️ **That exit 0 is the absence of a reading, not a clean one.** With
`yes` the gate short-circuits and examines **no file**. Run as a
**diagnostic only** with `--declaration no`, it exits 4 on two T1 tells:
`component.zod.ts:2689` (`pageSizeOptions`) and `:2692` (`pageSize`) —
*"a new key on a Zod object schema"*. Textually right, semantically
inverted for this diff: both members were already writable through
`z.unknown()`, which accepted everything; what the diff does is
**bound** them. That is a limitation of the matcher, not a signal about
this PR, and it is in the acceptance notes below rather than repaired
here.

## Acceptance notes

*The two paragraphs below were added by the `domain:spec#3` seat after
the body's single dev write, on the dev's own hand-over; ⛔ a dev writes
a PR body once, at creation.*

**The migration registry, with four open PRs adding entries to it.**
Mine, objectstack-ai#19090, objectstack-ai#19084 and objectstack-ai#18319 each add one semantic entry. Identity
cannot collide silently: the entry id IS the identity and the **filename
is a function of it**, so a duplicate would be a loud git add/add
conflict — the generator says so in as many words, and the four ids are
four distinct files. Order is **derived** `(major, id)` from the
directory listing, with no index file and no positional consumer
(`migrations/chain.ts` keys by MAJOR, `MIGRATIONS_BY_MAJOR[m]`), so a
clean text merge cannot express a wrong *meaning* — the `18.` prefix is
the protocol-major bucket, not a sequence number. The gate is `pnpm
--filter @objectstack/spec check:migration-registry`, run at exit 0
(「229 semantic, 195 retired-key, 181 retired-def」 current): it proves
the emitted regions equal what the entries directory says, so a merge
that dropped one side reds and one that kept both out of order reds too.
Adjacency measured over the 141 existing `18.*` entries plus the four in
flight: **7 / 49 / 61** existing entries lie between mine and objectstack-ai#19090 /
objectstack-ai#19084 / objectstack-ai#18319 — no pair is adjacent, and the register's own
insertion-only property then predicts a clean, current union whatever
the landing order. ⚠️ And `registry.ts` is deliberately **NOT** in the
`merge=os-regen` register (classified MIXED, 「a deferral would launder
the prose」), so a conflict there is **loud and a human's** — the
silent-drop class does not reach it.

**The hand-written docs negative, recorded so it is not reopened.**
Probe: hand-written `content/docs` trees (excluding `references/` and
`releases/`) authoring a `pageSize` value this narrowing refuses (`0`,
negative, decimal) → **ZERO**. **Lit control, same instrument:** it does
find authored `pageSize` occurrences —
`content/docs/api/data-api.mdx:42` (`?pageSize=5`) and
`content/docs/api/error-catalog.mdx:151` — over 2 hand-written pages and
9 pages including the generated tree, so the zero is a reading rather
than a dead grep. **Attribution, which is the part that matters:**
neither control hit is this door's `pagination.pageSize` —
`data-api.mdx` documents `pageSize` as an *unknown REST query parameter*
refused in favour of `top` / `$top` / `limit`, and the remaining pages
are the metadata response shape, the object page and the metadata-plugin
page. Four different `pageSize` members, none of them this one. ⇒
nothing owed on the hand-written side; the generated
`content/docs/references/ui/component.mdx` already moved in this diff.
The attribution step is the prescription of **objectstack-ai#19093**, filed today
after a name-based hit produced a false stop-the-line alarm on a sibling
PR.


Observations found in passing. ⛔ None is filed as a card by this PR, and
none is in its scope.

- **The widening-tells matcher cannot tell a narrowing-inside-a-bag from
a widening.** A PR that honestly declares `Clause-②: no (narrowing)` — a
legal, precedented declaration
(`.changeset/17499-groupbyfield-non-padded.md` carries exactly it) — and
bounds a member inside a previously-`z.unknown()` bag is blocked at exit
4 by a T1 tell that names the bound as a widening, because the matcher
reads the added key text and not the member's prior schema. Reproduced
on this diff, above. The honest declaration is the blocked one. The
successor: the next accept-set narrowing on this board. Dedupe words:
`widening-tells T1 narrowing inside z.unknown bag`,
`check-widening-tells false tell narrowing`, `clause-2 no narrowing
blocked exit 4`.
- **`frozenColumns: z.number().optional()`** on this same door
(`component.zod.ts`) is unbounded, and the renderer reads it as a
leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no
measured consumer breakage, and it is not this card's member. Noted, not
filed. The successor is any future PR on this door's numeric members.
- **`pagination: false` / `pagination: true` on `data-table` /
`object-data-table`** is authored in objectui and those props are not
declared in `ComponentPropsMap` at all, so nothing in this repo judges
them. Noted, not filed; that is the sibling repo's declaration surface,
not this door's.

## Notes for the reviewer

- ⛔ This PR does **not** hang, clear or touch `needs:contract-review`,
and writes **no label** — both carriers are the seat's write. `Clause-②:
yes` is here because triage ruled it; ⛔ this author does not review its
own clause-② verdict.
- `packages/spec/api-surface-declarations/ui.txt` moved because the
declaration text moved. PR objectstack-ai#19024 removes all 17 of those shards; a
deletion-versus-modification conflict there resolves in favour of the
deletion and is expected — ⛔ not pre-solved here.
- No governed surface is in the diff (checked against
`GOVERNED_SURFACES` in `scripts/pm/check-governed-merges.mjs`):
`docs/audits/` is not `docs/adr/`.
- objectui#9853 is the consumer half's card and objectui#9896 its landed
repair; this is the declaration half and was never a prerequisite for
it. objectstack#18972 names this same class on the declaration side, and
objectstack-ai#19083 landed its `scale` instance three commits before this branch's
merge base.

---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
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:data size/m tests tooling

Projects

None yet

2 participants