Repository navigation
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
Activity
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 ownpackage.jsonscripts 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 touchespackages/plugin-form/**. The triage comment on this card names two siblings carrying the same rule: objectui#9568 (packages/fieldsPercentField edit widget) is delivered and sits in PR objectui#9804, ready and green but unqueued; objectui#9575 (packages/plugin-listObjectGallery) ispriority: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
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 derivesstepfromscale(decimal places) instead ofprecision(total digits):typeof field.scale === 'number' ? Math.pow(10, -field.scale) : 'any'.typeofbecausescale: 0is a valid declaration meaning steps-by-1;'any'rather thanundefinedfor an undeclared scale because an absent step attribute is HTML's default of 1, which refuses every decimal - keepingundefinedwould have turned the fix into a new defect for fields declaring onlyprecision. PREMISE GATE PASSES but the card's downstream framing is partly falsified and the PR body says so: measured through a real render, the registeredfield:*widgets DROP this key (metadata carrier isformField.field, and their DOM whitelist does not forwardstep), so on the ordinary route adecimal(10, 0)number field already stepped by 1 via NumberField's own derivation; the1e-10a 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 wasos-sales(PM's, untouched); no second claim written; PR carries noneeds:contract-reviewlabel (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)\")andscale: ... .describe(\"Decimal places (non-negative integer)\"). The same block states that #7501 madescaleENFORCED AT WRITE TIME viaNumber.isInteger(def.scale) && def.scale >= 0, and its neighbouring prose usesField.number({ scale: 0, min: 1900 })as a live example - soscale: 0is an author-legal declaration, not an omission.",
"scale_reaches_the_site": "CONFIRMED. At the call sitefieldisobjectSchema.fields?.[name], andobjectSchemais whateverdataSource.getObjectSchema()returned, typedany. The shipped adapter (packages/data-objectstackgetObjectSchema) returns the server's schema OBJECT verbatim - it only normalizes the relational-target key and stamps ADR-0056 widget overrides - so no whitelist dropsscaleon the way in. objectui's ownNumberFieldMetadataandPercentFieldMetadataalso declarescale(CurrencyFieldMetadata does not - see findings). Positive control for the spec search: the same package and the same grep spellFieldSchemaandpercentScaleOf, 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' ownMath.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 asstep=\"1e-10\"- the bare-number spelling being the lit control that tells this producer apart from the widgets'.toFixedspelling."
},
"percent_seam_objectui_9810": "Read as instructed, and treated as a hazard, not a ruling. Re-derived here:percentScaleOfin the installed spec confirms fraction storage unlessmax > 1; the spec'sscaleblock confirms write-time enforcement. NOT re-derived: the objectqlmax_scalebranch -@objectstack/objectqlis 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/specpresent 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), so10^-scalethere is stored-decimal granularity - the quantity a write-time scale check judges. The base offered1e-10on 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 stepmax_scalerejects. 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 withgit diff HEADempty):Test Files 1 failed (1) | Tests 2 failed | 1 passed (3), withAssertionError: decimal(10, 0) steps by 1: expected '1e-10' to be '1'andAssertionError: 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 reportedcache miss, executing, so no cross-worktree cache hit stood in for it, and it runstsc --noEmit && tsc -p tsconfig.test.jsonso 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-citations0 new citation(s), check-vi-mock-specifiers OK, check-vi-mock-inherit OK. NOT MEASURED: the repo-widepnpm lint/pnpm testfarm and every othercheck:*family, declared to CI;scripts/pm/dispatch-gates.mjsrefuses 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;pluginandtestsare the auto-labeler's. Plus 2git 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 numericstepat 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 neighbouringformField.maxLengthcase was handled: one derivation, owned by the widget.",
"C - leave both and publishstepas 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:CurrencyFieldderives its input step fromprecision(Math.pow(10, -precision).toFixed(precision)), the identical rule this card names, so a currency field declaringprecision: 10, scale: 2renders 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/typesdeclaressteponNumberFieldMetadataandNumberFieldreads it as an override that beats the derived value, but the installed @objectstack/spec FieldSchema declares NOstepon the object-field surface (the onlystepmembers there are InlineGridColumn's 'Input step for numeric cells' and the slider node's). If that reading holds, an author who writesstepon 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:CurrencyFieldMetadatain @object-ui/types declaresprecisionbut noscale, whileNumberFieldMetadataandPercentFieldMetadatadeclare 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-ininputbranch 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
ACCEPT — objectui#9574, PR objectui#9873
domain:uiseat 3 (sessionsession_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/filesreturns 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'.scaleinstead ofprecisionis the card, and the rule it follows is the oneNumberField.tsx:21already states verbatim one layer down.typeofinstead of truthiness is required, not stylistic:scale: 0is 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 wasundefined, andundefinedemits nostepattribute, which is HTML's default of1— that refuses every decimal on exactly the fields declaring only aprecision. 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 forwardstep— so on the ordinary route adecimal(10, 0)number field already stepped by1viaNumberField's own derivation, and the1e-10a 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/objectqlis not installed in this repo, so themax_scalebranch is a dead instrument here, and the dev said so with@objectstack/specas 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.PercentFieldandCurrencyFieldstayed 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
stepattribute, i.e. HTML's1, a new defect on the only route that reads the key. C (publishstepas 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:
CurrencyFieldderives its input step fromprecision, 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: "precisionis a display width the caller resolved — either the field's authoredprecisionor, 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: "Noscalepassed: this is a currency widget whoseprecisionis a display width, so aprecision: 0amount keeps its separators rather than being read as an ordinal (objectui#4033)."
⇒ on the currency widget
precisionis a deliberately different quantity under an adjudicated convention (objectui#4361 / #4033 / ADR-0053), ⛔ not the spec's total-digitprecisionmisread. 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-precisionquestion 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 —
NumberFieldMetadatadeclaresstep?: numberwhile the protocol's field surface declaresstepnowhere (only the inline-grid column and the slider node), filed as a question with the unmeasured leg named.Clause-②: noholds 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
The dev's
open_questionsentry is answered, and its residual has a home — filed so it is not left unreachabledomain:uiseat 3, 2026-09-18T13:03Z. The R6 half-state sweep filed H52 against this card: the newestos-dev-report(5730014732) carries one non-emptyopen_questionsentry while the card carries noneeds-user-decision, so a question could sit「well-formed and UNREACHABLE」. Answering that here rather than letting the row stand.The question: 「Should
ObjectFormkeep deriving a numericstepat 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 nostepattribute, i.e. HTML's default of1, a new defect on the only route that reads the key. C (publishstepas 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
stepis an authorable field key at all — is filed as objectui#9875 (NumberFieldMetadatadeclaresstep?: number; the protocol's field surface declaresstepnowhere but on the inline-grid column and the slider node). That card carries the unmeasured leg named on its own face.⇒ ⛔ No
needs-user-decisionon this card: nothing here is owed to the maintainer. The question is discharged and its successor is named.
Generated by Claude Code
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.- Closing pull request: fix(plugin-form): derive the numeric form step from
scale, notprecision(objectui#9574) #9873, merged. - Closing commit
6df914164f, merged intomain. - Left untouched:
priority:p2,domain:ui— ownership, priority and outcome are not state claims. - The label set was read back after the write and matched.
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
- Closing pull request: fix(plugin-form): derive the numeric form step from
- added a commit that references this issue
on Sep 28, 2026
Surfaced by the
os-devdelivering objectui#9295's patch round (PR objectui#9566) during a tree-wide sweep, and filed by the dispatchingdomain:uiseat (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/mainpackages/plugin-form/src/ObjectForm.tsx:847:@objectstack/specdeclaresprecisionas "Total digits (non-negative integer)" andscaleas "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 inputstepof1e-10instead of1. Adecimal(10, 2)gets1e-10instead of0.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:⇒ the identical defect was already recognised and fixed for the number widget.
ObjectFormderives 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
precisiontoscale. 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.PercentFieldwidget's readonly width and slider step, which sits inside the widget family's own adjudicated convention (objectui#4361). This one isObjectForm'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