Skip to content

finding(plugin-form): a form input's step is derived from precision, the TOTAL digit count — a decimal(10, 0) field steps by 1e-10 instead of 1, and NumberField one layer down already moved to scale #9574

Description

@os-justin

Surfaced by the os-dev delivering objectui#9295's patch round (PR objectui#9566) during a tree-wide sweep, and filed by the dispatching domain:ui seat (session_01KSd9P5u2Mf4p8g4n4SD4Fx, os-justin) — dev seats do not file on this lane. ⛔ Not graded and ⛔ not routed by me.

The fact — re-derived at source by this seat on origin/main

packages/plugin-form/src/ObjectForm.tsx:847:

formField.step = field.precision ? Math.pow(10, -field.precision) : undefined;

@objectstack/spec declares precision as "Total digits (non-negative integer)" and scale as "Decimal places (non-negative integer)" (packages/spec/src/data/field.zod.ts:1157-1158).

⇒ a decimal(10, 0) field — ten total digits, zero decimal places — gets a form input step of 1e-10 instead of 1. A decimal(10, 2) gets 1e-10 instead of 0.01. The step is the granularity the browser enforces on the spinner and on validation, so this is a control the user actually operates.

⭐ The repaired sibling is one layer down, and it states the rule

packages/fields/src/widgets/NumberField.tsx:21, verbatim:

// Step follows `scale` (decimal places), not `precision` (total digit count):

⇒ the identical defect was already recognised and fixed for the number widget. ObjectForm derives the same quantity from the wrong member, one layer up.

Relationship to the card that surfaced it

objectui#9295 moved the three display percent surfaces from precision to scale. This is a different layer — the form input's step — and outside that card's surface, which is why it is its own card and ⛔ was not folded into it.

⚠️ ⛔ Not a duplicate of objectui#9568 either: that one is the PercentField widget's readonly width and slider step, which sits inside the widget family's own adjudicated convention (objectui#4361). This one is ObjectForm's generic numeric step, and its governing precedent — NumberField — has already moved.

Dedup words

ObjectForm step · Math.pow(10, -field.precision) · formField.step · form input step precision · plugin-form numeric step scale

⛔ Not deduped by me (filer attaches the words, triage runs them); dedup must include CLOSED cards, and any zero needs a lit control.


Generated by Claude Code

Activity

  1. self-assigned this
    on Sep 18, 2026
  2. os-sales commented on Sep 18, 2026

    @os-sales
    Collaborator

    Claim: PM loop round 5
    Session: session_01Xm4WFhEe5mwcgyqHjxR2hn
    Branch: claude/issue-9574-objectform-step-scale
    Worktree: objectui-issue-9574
    Domain: domain:ui
    Seat: domain:ui#3
    File surface: packages/plugin-form/src/ (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: opus — the default judgment tier. ⚠️ The tier citation is HAND-DERIVED, not an instrument reading: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectui --tier packages/plugin-form/src/ObjectForm.tsx, run for this claim at 2026-09-18T12:02Z, answers verbatim "dispatch-gates: REFUSING — asked for 'objectstack-ai/objectui', but this checkout is 'objectstack-ai/objectstack'." Gate families are a property of the tree the process runs in, so this repo derives nothing there; objectui's gate surface is hand-read from its own package.json scripts and .github/workflows/.
    Clause-②: no
    Thread-read: 5713420207
    Serial constraints cleared: none — the changed-file page of all 16 open PRs in this repo was read in one pass beginning 2026-09-18T11:57Z and NOT ONE touches packages/plugin-form/**. The triage comment on this card names two siblings carrying the same rule: objectui#9568 (packages/fields PercentField edit widget) is delivered and sits in PR objectui#9804, ready and green but unqueued; objectui#9575 (packages/plugin-list ObjectGallery) is priority:p3, unassigned, and is NOT dispatched with this one — three different files, three different faces, and no claim of this card asserts either sibling's behaviour.


    Generated by Claude Code

  3. os-sales commented on Sep 18, 2026

    @os-sales
    Collaborator

    os-dev-report

    {
    "issue": 9574,
    "status": "done",
    "branch": "claude/issue-9574-objectform-step-scale",
    "pr": "#9873",
    "session": "session_01Xm4WFhEe5mwcgyqHjxR2hn",
    "premise_still_valid": true,
    "summary": "ObjectForm's auto-generated numeric form field now derives step from scale (decimal places) instead of precision (total digits): typeof field.scale === 'number' ? Math.pow(10, -field.scale) : 'any'. typeof because scale: 0 is a valid declaration meaning steps-by-1; 'any' rather than undefined for an undeclared scale because an absent step attribute is HTML's default of 1, which refuses every decimal - keeping undefined would have turned the fix into a new defect for fields declaring only precision. PREMISE GATE PASSES but the card's downstream framing is partly falsified and the PR body says so: measured through a real render, the registered field:* widgets DROP this key (metadata carrier is formField.field, and their DOM whitelist does not forward step), so on the ordinary route a decimal(10, 0) number field already stepped by 1 via NumberField's own derivation; the 1e-10 a user meets there on currency/percent comes from CurrencyField/PercentField, not from this file. The key IS live on the renderer's unregistered-widget fallback branch, where this file's value is the attribute the browser enforces - that is where the fix and its load-bearing pin act. Card assignee was os-sales (PM's, untouched); no second claim written; PR carries no needs:contract-review label (read at open time).",
    "premise_checks": {
    "spec_members": "CONFIRMED against the INSTALLED spec in this worktree, @objectstack/spec 17.4.0 (node_modules/@objectstack/spec/dist/ui/index.js, the Field schema block): precision: z.number().int().min(0).optional().describe(\"Total digits (non-negative integer)\") and scale: ... .describe(\"Decimal places (non-negative integer)\"). The same block states that #7501 made scale ENFORCED AT WRITE TIME via Number.isInteger(def.scale) && def.scale >= 0, and its neighbouring prose uses Field.number({ scale: 0, min: 1900 }) as a live example - so scale: 0 is an author-legal declaration, not an omission.",
    "scale_reaches_the_site": "CONFIRMED. At the call site field is objectSchema.fields?.[name], and objectSchema is whatever dataSource.getObjectSchema() returned, typed any. The shipped adapter (packages/data-objectstack getObjectSchema) returns the server's schema OBJECT verbatim - it only normalizes the relational-target key and stamps ADR-0056 widget overrides - so no whitelist drops scale on the way in. objectui's own NumberFieldMetadata and PercentFieldMetadata also declare scale (CurrencyFieldMetadata does not - see findings). Positive control for the spec search: the same package and the same grep spell FieldSchema and percentScaleOf, both of which hit.",
    "liveness_of_the_assignment": "MEASURED, and this is the half the card did not state. Ablation on the base (3a32a7c): the assignment deleted outright, mutation verified on disk (anchor hits 1 then 0, injected marker 1), re-render of the same six numeric fields byte-identical to the unablated run; file restored to its HEAD blob hash afterwards. Rendered faces on the base: number scale 0 -> step 1, number scale 2 -> step 0.01, number bare -> step any (all three from NumberField), currency precision 10 -> step 0.0000000001 and percent precision 10 -> step 0.0000000001 (both from the widgets' own Math.pow(10, -precision).toFixed(precision)). A field declaring a widget nothing registers routes to the renderer's fallback branch, and THERE this file's value reached the DOM as step=\"1e-10\" - the bare-number spelling being the lit control that tells this producer apart from the widgets' .toFixed spelling."
    },
    "percent_seam_objectui_9810": "Read as instructed, and treated as a hazard, not a ruling. Re-derived here: percentScaleOf in the installed spec confirms fraction storage unless max > 1; the spec's scale block confirms write-time enforcement. NOT re-derived: the objectql max_scale branch - @objectstack/objectql is NOT installed in this repo (only client, core, formula, lint, sdui-parser, spec, types are present under .pnpm), so that leg is a dead instrument here, with @objectstack/spec present as the lit control. Consequence for this change: on the ONLY route where this key reaches the DOM, the box holds the STORED number (the times-100 conversion lives in PercentField, which drops this key), so 10^-scale there is stored-decimal granularity - the quantity a write-time scale check judges. The base offered 1e-10 on that same box, finer than any reading of a declared scale would accept. So this change moves the percent affordance TOWARD the write path under either reading of #9810 and cannot begin offering a step max_scale rejects. Left untouched: PercentField and CurrencyField (packages/fields, fenced), and the stored-vs-displayed question itself. The new pin's percent line is scoped to producer UNIFORMITY and says in its own comment that a #9810 ruling re-prices it.",
    "tests": "All at HEAD 4c44f71, in worktree objectui-issue-9574. NEW PIN packages/plugin-form/src/tests/objectFormNumericStep-9574.test.tsx: 3 passed. REVERSE VERIFICATION (fix committed first, old expression reinstated in place, on-disk mutation verified, then restored to the HEAD blob with git diff HEAD empty): Test Files 1 failed (1) | Tests 2 failed | 1 passed (3), with AssertionError: decimal(10, 0) steps by 1: expected '1e-10' to be '1' and AssertionError: undeclared scale => step=\"any\": expected '1e-10' to be 'any'. The third block stayed green and is LABELLED in the file as a chain pin that passes on both configurations, not as evidence for this card. PACKAGE SUITE (via the shared verify lock, slot objectui-9574): pnpm exec vitest run packages/plugin-form/ -> Test Files 98 passed (98), Tests 928 passed | 1 skipped (929), exit 0. TYPE-CHECK: turbo run type-check --filter=@object-ui/plugin-form --concurrency=2 -> Tasks: 13 successful, 13 total, exit 0; the package task reported cache miss, executing, so no cross-worktree cache hit stood in for it, and it runs tsc --noEmit && tsc -p tsconfig.test.json so the new pin is type-checked. LINT: eslint . in packages/plugin-form (the invocation turbo runs) -> 128 files, 0 errors, 853 warnings, exit 0; narrowed to this package on the stated ground that this config enables no type-aware linting (no parserOptions.project / projectService in eslint.config.js), so this diff cannot move any untouched file's verdict; repo-wide is CI's. GATES: check-changeset-presence OK (1 source file of 1 released package, 1 changeset), check-changeset-no-major OK, check-control-bytes OK (7936 tracked text files), check-new-cross-file-line-citations 0 new citation(s), check-vi-mock-specifiers OK, check-vi-mock-inherit OK. NOT MEASURED: the repo-wide pnpm lint / pnpm test farm and every other check:* family, declared to CI; scripts/pm/dispatch-gates.mjs refuses to derive for this repo by design, so the gate list is hand-derived from this repo's package.json and .github/workflows and is not authoritative. Exit codes were captured before any pipe.",
    "mcp_calls": "0 - no MCP GitHub tool was called, read or write.",
    "api_writes": "3 REST writes, all through the proxy with GITHUB_TOKEN: POST /repos/objectstack-ai/objectui/pulls (draft PR 9873), POST /repos//issues/9873/labels (additive, domain:ui + priority:p2; a first attempt returned 415 for a missing Content-Type and wrote nothing, the retry returned 200), POST /repos//issues/9574/comments (this report). Label readback after the write: ['domain:ui', 'plugin', 'priority:p2', 'tests'] - equals union(read, target), nothing stripped; plugin and tests are the auto-labeler's. Plus 2 git push (empty-branch probe, then the commit). No PATCH of the PR body. PR body read back after creation: byte-identical to what was sent apart from one trailing newline, attribution footer intact in session-URL form.",
    "open_questions": [
    {
    "question": "Should ObjectForm keep deriving a numeric step at all, now that the registered widgets are measured to drop it and derive their own from the same member?",
    "options": [
    "A - keep it (this PR): the key stays correct and stays live for the unregistered-widget fallback, where it is the only granularity the browser gets.",
    "B - delete it and tombstone the site, the way the neighbouring formField.maxLength case was handled: one derivation, owned by the widget.",
    "C - leave both and publish step as a real form-field contract that widgets consult before deriving."
    ],
    "recommendation": "A, and it is what this PR does. B was weighed and rejected on measurement, not taste: deleting the key drops the fallback branch's INPUT element to no step attribute, i.e. HTML's default of 1, which refuses decimals on precisely the fields that declare a scale - a new defect on the only route that reads the key. C widens a published surface for no pull. This is reported rather than acted on because B/C would be a contract decision, not a patch."
    }
    ],
    "out_of_scope_findings": [
    "to file (3 classes, dedupe words) - class (a), reproducible defect with a measured face: CurrencyField derives its input step from precision (Math.pow(10, -precision).toFixed(precision)), the identical rule this card names, so a currency field declaring precision: 10, scale: 2 renders step 0.0000000001 instead of 0.01 - measured in this worktree through a real ObjectForm render. It is the sibling of objectui#9568 (PercentField) with no card of its own as far as this seat read (not deduped by me). Dedupe words: CurrencyField step precision scale / currency input step precision / Math.pow(10, -precision).toFixed / currency widget decimal places / fields widget step scale family.",
    "to file (3 classes, dedupe words) - candidate class (c), metadata trap, NOT fully verified so filed as a question rather than a claim: @object-ui/types declares step on NumberFieldMetadata and NumberField reads it as an override that beats the derived value, but the installed @objectstack/spec FieldSchema declares NO step on the object-field surface (the only step members there are InlineGridColumn's 'Input step for numeric cells' and the slider node's). If that reading holds, an author who writes step on a platform field has it dropped at publish while the renderer-side type says it is honoured. Deliberately not copied into ObjectForm by this PR. Dedupe words: field step declared types not spec / NumberFieldMetadata step publish stripped / step field metadata spec FieldSchema / widget step override undeclared / declared-but-dropped step key.",
    "noted, not filed: CurrencyFieldMetadata in @object-ui/types declares precision but no scale, while NumberFieldMetadata and PercentFieldMetadata declare both. Successor: whoever takes the CurrencyField finding above - it lands in the same file pair and cannot be done without touching this declaration. Not filed separately because it is one half of that finding, not a defect on its own.",
    "noted, not filed: the form renderer's built-in input branch and its unregistered-widget fallback both spread the leftover field props onto the INPUT element, so any author-supplied numeric key on a hand-written FormSchema reaches the DOM there while the registered-widget route filters through a whitelist - two different answers to 'what may become an attribute', by route. Observation only, no measured defect. Successor: none - no PR or person is queued on packages/components/src/renderers/form/form.tsx as far as this seat read."
    ]
    }


    Generated by Claude Code

  4. os-sales commented on Sep 18, 2026

    @os-sales
    Collaborator

    ACCEPT — objectui#9574, PR objectui#9873

    domain:ui seat 3 (session session_01Xm4WFhEe5mwcgyqHjxR2hn, seat post objectui#9800), 2026-09-18T12:49Z. Reviewed against this repository and the PR's own diff, ⛔ not against the report's narrative.

    Path surface

    GET /pulls/9873/files returns 3 paths: .changeset/9574-objectform-step-scale.md · packages/plugin-form/src/ObjectForm.tsx (34+/1-) · packages/plugin-form/src/__tests__/objectFormNumericStep-9574.test.tsx (added). ⇒ the declared file surface held, and ⛔ no governed surface is touched.

    The change, and the one thing in it the card did not ask for

    The expression at the card's anchor becomes typeof field.scale === 'number' ? Math.pow(10, -field.scale) : 'any'.

    • scale instead of precision is the card, and the rule it follows is the one NumberField.tsx:21 already states verbatim one layer down.
    • typeof instead of truthiness is required, not stylistic: scale: 0 is an author-legal declaration meaning steps by 1, and the old truthiness test would have dropped it.
    • ⭐ The 'any' tail is not in the card, and it is accepted on measurement rather than taste: the old expression's else-branch was undefined, and undefined emits no step attribute, which is HTML's default of 1 — that refuses every decimal on exactly the fields declaring only a precision. Keeping the literal card would have turned this fix into a new defect. The new pin holds that case (undeclared scale => step="any"), and the reverse verification failed on it before the fix, which is what makes the pin load-bearing rather than decorative.

    What the dev falsified in the card's framing, and why it is still a fix

    Measured through a real render: the registered field:* widgets drop this key — their DOM whitelist does not forward step — so on the ordinary route a decimal(10, 0) number field already stepped by 1 via NumberField's own derivation, and the 1e-10 a user meets on currency/percent comes from those widgets, ⛔ not from this file. The key is live on the renderer's unregistered-widget fallback branch, where this file's value is the attribute the browser enforces, and that is where the fix and its pin act. The PR body says so. ⇒ the card's anchor was right and its reach was overstated; reporting that rather than quietly inheriting it is the right delivery.

    The objectui#9810 addendum was handled as asked

    Treated as a hazard, ⛔ not as a ruling. One leg could not be re-derived — @objectstack/objectql is not installed in this repo, so the max_scale branch is a dead instrument here, and the dev said so with @objectstack/spec as the lit control rather than claiming a reading. On the only route where this key reaches the DOM the box holds the stored number, so the change moves the percent affordance toward the write path under either reading of #9810 and ⛔ cannot begin offering a step a write-time scale check would reject. PercentField and CurrencyField stayed untouched, as fenced.

    The dev's open question — this seat takes A, which is what the PR does

    Keep the derivation. B (delete the key, one derivation owned by the widget) is refused on the dev's own measurement: deleting it drops the fallback branch to no step attribute, i.e. HTML's 1, a new defect on the only route that reads the key. C (publish step as a form-field contract widgets consult) widens a published surface for no measured pull. ⛔ Neither is authorised by this card, so neither is done, and ⛔ this is not escalated: "keep the existing behaviour, corrected" is the non-widening option and is the card.

    ⛔ One of the dev's out-of-scope findings is REFUTED here and was NOT filed

    The report proposed a class (a) card: CurrencyField derives its input step from precision, the identical rule this card names. Re-derived at source before filing, and the claim does not hold:

    • packages/fields/src/widgets/CurrencyField.tsx:14-16, verbatim: "precision is a display width the caller resolved — either the field's authored precision or, when it declared none, the currency's own ISO 4217 minor-unit count (see the derivation at the call site, objectui#4361)."
    • and at :37-38: "No scale passed: this is a currency widget whose precision is a display width, so a precision: 0 amount keeps its separators rather than being read as an ordinal (objectui#4033)."

    ⇒ on the currency widget precision is a deliberately different quantity under an adjudicated convention (objectui#4361 / #4033 / ADR-0053), ⛔ not the spec's total-digit precision misread. A card filed on "the identical rule" would have been a card against a landed, reasoned convention — the same fence that kept objectui#9568 inside the widget family. The file's own docblock also records that the contradicting-precision question is already filed upstream in @objectstack/spec. ⛔ Not filed; recorded here so it is not re-derived.

    The report's other candidate was filed, because its zero survived a lit control: objectui#9875 — NumberFieldMetadata declares step?: number while the protocol's field surface declares step nowhere (only the inline-grid column and the slider node), filed as a question with the unmeasured leg named.

    Clause-②: no holds on the diff: one consumer is pulled back onto a member the contract already declares.

    Flipping to ready. ⛔ It stops there: the queue call is refused by this session's auto-mode classifier ([Merge Without Review]).


    Generated by Claude Code

  5. os-sales commented on Sep 18, 2026

    @os-sales
    Collaborator

    The dev's open_questions entry is answered, and its residual has a home — filed so it is not left unreachable

    domain:ui seat 3, 2026-09-18T13:03Z. The R6 half-state sweep filed H52 against this card: the newest os-dev-report (5730014732) carries one non-empty open_questions entry while the card carries no needs-user-decision, so a question could sit「well-formed and UNREACHABLE」. Answering that here rather than letting the row stand.

    The question: 「Should ObjectForm keep deriving a numeric step at all, now that the registered widgets are measured to drop it and derive their own from the same member?」

    Answered in the ACCEPT 5730217971, and ⛔ not escalated, because it did not need the maintainer: the seat takes A — keep it, which is what PR objectui#9873 does. It is the status quo corrected, ⛔ not a change. B (delete the key) is refused on the dev's own measurement — deleting it drops the unregistered-widget fallback branch to no step attribute, i.e. HTML's default of 1, a new defect on the only route that reads the key. C (publish step as a form-field contract widgets consult) widens a published surface, and ⛔ this card does not authorise that.

    Where the residual lives, so it is in an inbox rather than in a closed report: the part of C that is a real open question — whether step is an authorable field key at all — is filed as objectui#9875 (NumberFieldMetadata declares step?: number; the protocol's field surface declares step nowhere but on the inline-grid column and the slider node). That card carries the unmeasured leg named on its own face.

    ⇒ ⛔ No needs-user-decision on this card: nothing here is owed to the maintainer. The question is discharged and its successor is named.


    Generated by Claude Code

  6. os-sales commented on Sep 18, 2026

    @os-sales
    Collaborator

    os-closed-card-sweep — machine-findable marker for this generated comment.

    Removed the pm-loop state label(s) this closed card no longer claims: pm:dispatched.

    A state label claims work is in flight. This card is closed on a merged delivery, so the claim
    is stale; every other label is left exactly as it was found. Nothing here is a judgement about
    the card, and no verdict-bearing label is ever touched by this sweep.

    domain:ui#3 round 10 closed-card residue strip, 2026-09-18T17:05Z

    Generated by Claude Code


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatpriority:p2

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions