Skip to content

fix(spec): a currency field's precision is total digits — stop refusing precision: 18 on a fixed-USD field (#20011) - #20209

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20011-currency-field-precision-anchor
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/issue-20011-currency-field-precision-anchor

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20011
Clause-②: yes

FieldSchema stops comparing a currency field's field-level precision with the ISO 4217 fraction digits of its fixed currency. precision: 18 on a fixed-USD field now parses. currencyConfig.precision is a different key, and its check is untouched.

What was wrong (reproduced on origin/main e4471e64, before any edit)

FieldSchema.safeParse through the source, one field per row:

input (type: 'currency') result on e4471e64
precision: 18, fixed USD REFUSED at ['precision']
precision: 2, fixed USD accepted
precision: 1, fixed USD REFUSED at ['precision']
precision: 18, fixed JPY REFUSED at ['precision']
precision: 0, fixed JPY accepted
precision: 18, dynamic accepted
precision: 18, no currencyConfig accepted
type: 'number', precision: 18 accepted

The refusal text for the first row, which this PR removes from the field-level path:

currency USD has 2 fraction digits; precision: 18 contradicts it — the amount would render minor-unit digits the currency does not have, or drop digits it does (ISO 4217 / CLDR currencyData). Declare precision: 2, or omit precision and let renderers derive the width from the currency. Codes outside CLDR (crypto/custom) are not checked, and a field in dynamic currencyMode has no single currency to check against.

The key is declared precision: z.number().int().min(0).optional().describe('Total digits (non-negative integer)'). A DECIMAL(18,2) amount is precision: 18, and the remedy asked for a total-digit count of 2. Ruling 乙 on #19910 (5805782503): 「## Ruled — 乙: a currency's decimal places are the currency's, not a setting」.

Real producer: objectui's Studio field designer offers a "Precision" control on every numeric type, currency included (packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx:917-919 at the pin f8a9d0fb, patchDef({ precision: v })). An admin who types 18 on a fixed-USD currency field gets this refusal at save.

The premise, re-measured (it is false everywhere)

The check's own comment named its premise: 「the FIELD-level precision key doubles as the currency display width — objectui's CurrencyField reads it」. Before removing it, every face was checked for a read of field-level precision as decimal places. None was found.

objectui at the console pin f8a9d0fb0596 (.objectui-sha on main). git grep -w precision over non-test packages/** and apps/** *.ts / *.tsx gives 83 lines. Each one is one of these:

  • A comment or doc. For example, CurrencyField.tsx:64-78 says 「⛔ NOT the field-level precision」.
  • A type declaration. types/src/field-types.ts:407,436,446.
  • A pass-through copy into a metadata object: ObjectGrid.tsx:2863,3084,3250, RelatedList.tsx:1607, RecordDetailPanel.tsx:167, sectionFields.ts:321, ObjectGallery.tsx:367, useColumnSummary.ts:492.
  • The designer's write above.
  • formatPercent's own parameter of that name. Its callers pass scale.

The faces that format numbers:

  • CurrencyField derives its width from currencyFractionDigits(currency).
  • CurrencyCellRenderer calls formatCurrency(num, currency, locale) with no width argument.
  • NumberCellRenderer and PercentCellRenderer read scale.
  • The grid footer's currency arm calls formatCurrency and reads neither key.
  • ObjectMetricWidget resolves only the currency.
  • The detail chip reads scale.

objectstack. No packages/** source reads the field-level key. NUMERIC_COLUMN_REPRESENTATION.currency is a fixed (65, 30) column. driver-sql/src/builtin-column-collision.ts:77 says precision is 「this driver does not read it yet」. objectql has no write-seam total-digit check.

So the check guarded a reading that no longer exists. What it still did was refuse the ruled contract.

Decision: delete the check, keep no residual

Zone 2 of the dispatch asked whether some residual stays true, for example precision below the currency's fraction digits. Measured: no numeric type has a total-digit coherence rule. { type: 'number', precision: 1, scale: 2 } parses on main, and so does { type: 'percent', precision: 0, scale: 3 }. Four-axis reading:

  • Business need. Nothing reads the key, so a residual guards the internal consistency of a value with no effect. The census below finds no authored producer in any of the three repos.
  • Long-term fit. A total-digit coherence rule belongs next to a total-digit enforcement (DDL or the write seam), and neither exists. A currency-only, fixed-mode-only remnant would be the one such rule in the spec.
  • AI-authoring safety. The mistake an AI plausibly makes is precision: 2 meaning "decimals". Refusing that would narrow the accept set, which neither this card nor the dispatch authorizes; the dispatch requires that precision: 2 keep parsing. The larger trap is that the key is inert everywhere. That goes to the seat as a finding (Acceptance notes), not a new rule here.
  • Startup focus. A new refusal rule is a new gate, and the default is no.

A comment at the old site records why the comparison is absent, so it is not reinstated as an "improvement".

Changes

  • packages/spec/src/data/field.zod.ts: the field-level currencyPrecisionContradiction call inside FieldSchema's .superRefine is deleted, and a comment replaces it. The import comment is updated. Region fence: hunks only at the import comment and the old anchor. There is no hunk in CurrencyConfigSchema (its .superRefine / .overwrite and the precision default, [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992's region), in SelectOptionSchema, or in the requiredWhen / readonlyWhen region.

  • packages/spec/src/data/currency-fraction-digits.ts: doc comments only. The helper keeps both of its readers, in CurrencyConfigSchema's .superRefine and .overwrite. The docblock no longer says "shared by BOTH anchors", and it now states that the verdict is ⛔ not for the field-level key. The message text is unchanged.

  • packages/spec/src/data/currency-precision-iso4217.test.ts: the field-level block is rewritten (pins below). The header docblock is updated.

  • content/docs/data-modeling/field-types.mdx and validation-rules.mdx: both claimed a declared precision must agree with the currency. This PR makes that false for the field-level key, so the text now names currencyConfig.precision. It also says the field-level key is total digits and is never compared. New text in field-types.mdx:

    A currency amount's decimal places are its currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3 for KWD), not a field setting, so a currency field takes no scale: publish-time validation refuses the key. The field-level precision is the amount's total digit count — a DECIMAL(18,2) amount declares precision: 18 — and is never compared with the currency.

    The same section's table also listed scale ("Decimal places") as a currency property. That contradicts the new paragraph and the landed spec refusal (field.zod.ts, scale on currency is refused, with the same wording in protocol/objectql/schema.mdx), so the row is deleted.

  • .changeset/20011-currency-field-precision-total-digits.md: @objectstack/spec minor, Clause-②: yes (widening). Nothing is narrowed, so there is no ADR-0087 entry (check:adr-0087-registration exit 0).

Pins (currency-precision-iso4217.test.ts, block #20011 — field-level precision is total digits…)

  • precision: 18 on fixed USD parses, and data.precision is still 18.
  • precision: 2 on fixed USD still parses.
  • Flipped: precision: 2 on fixed JPY parses (DECIMAL(2,0)). This is the old rejects … (JPY + 2) pin.
  • Total digits parse on every fraction-digit class: JPY 0 / 12, USD 2 / 18, KWD 3 / 2 / 15, each with the authored value carried through.
  • No currency-only coherence rule: USD precision: 1 parses, as number with precision: 1, scale: 2 does.
  • Outside fixed mode (no currencyConfig, dynamic authored or defaulted), nothing changes. Non-currency types are untouched.
  • Through the object door: ObjectSchema.create with a fixed-USD precision: 18 field, then ObjectSchema.safeParse of the JSON round-trip, succeeds and keeps 18.
  • Kept refusal (genuine, a different key): currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' } through FieldSchema is refused at ['currencyConfig', 'precision'], code custom. The message contains currency JPY has 0 fraction digits and the prescription Declare \precision: 0``.
  • Flipped: the old both anchors fire independently pin. With both keys authored, exactly one issue is raised, at ['currencyConfig', 'precision'] (code + path + prescription asserted). The field-level key raises nothing.
  • The #7918 currencyConfig-level block, the #11423 materialization block and the digit-table block are untouched and green.

Pin sweep. Searched for fraction digits; and contradicts it — across objectstack, objectui at the pin, and cloud 48d70663. The only field-level pins were in this test file. The other currencyMode: 'fixed' fixtures (field.test.ts, field-currency-scale-refused.test.ts, three service-analytics tests) carry no field-level precision.

Reverse verification

Run on the committed fix accdab16 with scripts/ablation-replace.mjs plus its own trap restore. The mutation re-inserts the old field-level check under a marker. The marker count was 0, then 1 during the run, then 0 after. The blob went 26f8ef39 → 5c392b24 and back to 26f8ef39.

Expected direction: red. Observed: 6 failed, 24 passed, and the six are exactly the new acceptance pins. Restore was proven by blob == HEAD and an empty git diff HEAD, not by an exit code.

The first attempt was a no-op. My replacement contained the anchor text, so the anchor count could not drop, and the tool refused it before running the test. It was redone with a replacement that drops the anchor. The later merge of main touched neither mutated file.

Producer census (authored currency fields with a field-level precision)

The detector parses Field.currency({…}) and type: 'currency' object literals and reads their top-level keys.

tree non-test currency declarations with field-level precision control (tests, same detector)
objectstack e4471e64 (examples/**, packages/**) 31 0 194 declarations, 2 hits (object.test.ts:800 = 18, lint-liveness-properties.test.ts:780 = 2)
objectui f8a9d0fb 5 0 180 declarations, 14 hits
cloud 48d70663 0 0 44 declarations, 0 hits; no non-test field precision anywhere in packages/**

The one live producer is the Studio designer control named above.

Verification (final head 0e006877, after merging main at 172b4cf3)

  • @objectstack/spec suite (vitest run --project local): 540 files, 15871 passed, 2 todo.
  • @objectstack/spec typecheck (tsc --noEmit, scripts, test layer): exit 0, and the test-layer debt ledger held.
  • check:generated: all 15 generated artifacts up to date.
  • Consumer suites: @objectstack/lint 108 files / 4183 passed; @objectstack/platform-objects 55 / 911 passed.
  • Gates: dispatch-gates.mjs --commands derived 104 commands for these 6 paths. All 104 exit 0 at 0e006877. --ran reconciliation: 104 derived, 104 run, 0 NOT MEASURED, a derived zero because every line recorded its exit code.
    • At the final head, one first run (check:dual-build-cjs-loads) exited 3 (PREREQUISITE NOT MET). It was re-run to 0 once the packages it named were built.
    • The pre-merge pass at accdab16 had seven such refusals, all cleared the same way.
    • None of those refusals counts as a measurement.
  • ESLint, narrowed to the three touched .ts files:
    • All three resolve a config under --print-config.
    • --format json reports 3 files, 0 errors, 0 warnings.
    • eslint.config.mjs never enables type-aware linting (its own note at :326-328), so this diff cannot move any untouched file's verdict.
    • pnpm lint over the whole repo is CI's run.
  • NOT MEASURED locally, declared to CI:
    • Dogfood. No dogfood test pins currency precision.
    • The workspace, examples and downstream-contract type-check lanes.
    • The CI-only jobs these paths schedule.

Acceptance notes


Generated by Claude Code

…nst its currency's fraction digits

The FieldSchema check compared the field-level `precision` (declared
"Total digits") with the fixed currency's ISO 4217 fraction digits and
refused `precision: 18` on a USD amount, prescribing `precision: 2`.
Its premise, a renderer reading the key as the display width, is gone,
and a currency's decimal places are the currency's. The
`currencyConfig.precision` check is a different key and is unchanged.

Claude-Session: https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 1 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/currency-fraction-digits.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 1 changed file(s) yielded no anchor (packages/spec/src/data/currency-fraction-digits.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 207 client-bound route-ledger rows — the other 153 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 153: 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; 98 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from be91d11c842b2224db031c9fb404dc9425399a26 — the merge of head 0e006877963089d9c1f90b18a69614a15b3578b8 into base 1c8b320a894b31dcfc76c33c8e7a62b8986d893a, 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 be91d11c842b2224db031c9fb404dc9425399a26 && git checkout be91d11c842b2224db031c9fb404dc9425399a26
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1c8b320a894b31dcfc76c33c8e7a62b8986d893a 0e006877963089d9c1f90b18a69614a15b3578b8 && git checkout -B drift-repro 1c8b320a894b31dcfc76c33c8e7a62b8986d893a && git merge --no-ff 0e006877963089d9c1f90b18a69614a15b3578b8

node scripts/docs-audit/affected-docs.mjs --json 1c8b320a894b31dcfc76c33c8e7a62b8986d893a

⚠️ 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 1c8b320a894b31dcfc76c33c8e7a62b8986d893a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0e006877963089d9c1f90b18a69614a15b3578b8

① Derived judgments

  • The deleted guard is exactly the Contract question: should publish-time validation reject a declared currency precision that contradicts the currency's ISO 4217 digits? #7918 FIELD-level anchor and nothing else. git diff origin/main 0e006877 -- packages/spec/src/data/field.zod.ts has two hunks: the import comment (:31-34) and the FieldSchema.superRefine block that read field.type === 'currency' && field.precision !== undefined && field.currencyConfig?.currencyMode === 'fixed' → currencyPrecisionContradiction(defaultCurrency, precision) → ctx.addIssue({ path: ['precision'] }) (main :2314-2333), now a comment. The removed code only ever ADDED an issue (no transform), so every previously accepted field's output is byte-identical, as the changeset says. Right. Merge-base 172b4cf3..head is exactly the 6 files in diff.patch; the 3 commits main gained since (1c8b320a) touch none of them (identical per-file line counts in git diff --stat origin/main..head for those six).
  • What now parses that was refused before — measured, head vs origin/main field.zod.ts in a detached probe worktree (probe.mts, same node_modules). 30-row matrix (probe-head.jsonl / probe-main.jsonl; main = head's tree with origin/main:field.zod.ts swapped in, blob 8f35ae5d → 26f8ef39 restored, git diff HEAD empty). Refused→accepted, with the authored value carried: fixed USD precision 18 / 1 / 0, fixed JPY 2 / 18, fixed KWD 2 / 15, fixed with defaulted CNY 0, and ObjectSchema.create + JSON re-parse with fixed-USD 18 (main threw at fields.amount.precision; head keeps 18). Unchanged verdict AND message on every other row: agreeing values (USD 2, JPY 0, KWD 3), fail-open BTC 8, dynamic / defaulted-mode / no-currencyConfig, absent precision, precision: 2.5 (int guard), scale: 2 on currency (still refused at ['scale'], [finding] FieldSchema.scale on a currency field is offered to authors by the field designer, ignored by every display face, and still enforced on writes — half-live in the direction that surprises #19629), number 18, number 1+scale 2, percent 0+scale 3. Two rows still refuse with a SHORTER issue list: precision: -1 (main: int guard + contradiction; head: int guard only) and both keys authored (below). All 13 accepted-on-both rows are byte-identical (JSON.stringify equal). So the widening is exactly "field-level integer precision ≠ the fixed currency's CLDR fraction digits, known codes only" and nothing else.
  • currencyConfig.precision anchor ([finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992's region) refuses exactly as before. Call sites at head field.zod.ts:466 (.superRefine) and :506 (.overwrite), identical text to main :465/:505 (one-line shift from the import comment; no hunk in CurrencyConfigSchema, SelectOptionSchema or the requiredWhen/readonlyWhen region). Probed at head, same as main: currencyConfig: { precision: 2, currencyMode: 'fixed', defaultCurrency: 'JPY' } and KWD refused at ['currencyConfig','precision'] with "currency JPY has 0 fraction digits; precision: 2 contradicts it …"; { precision: 0, currencyMode: 'fixed' } (defaulted CNY) refused; the scale alias under currencyConfig still refused as an unrecognized key with the precision suggestion; agreeing USD 2 accepted with identical output. With BOTH keys authored (field precision: 2 + currencyConfig.precision: 2, fixed JPY): main raised two issues, head raises exactly one, at ['currencyConfig','precision'] — the field is still refused. Right.
  • The premise is TRUE at the objectui pin f8a9d0fb0596 (.objectui-sha on main and head). git grep -n -w precision f8a9d0fb -- 'packages/**/*.ts{,x}' 'apps/**/*.ts{,x}' minus tests = 83 lines; the 27 non-comment lines are: type declarations (types/src/field-types.ts:407,436,446, form-spec.ts:191), i18n labels, formatPercent/formatPercentBody's own parameter name (fields/src/index.tsx:588-659; its callers pass scale, a pattern-derived decimals, or undefined), pass-through copies into field meta (ObjectGrid.tsx:2863,3084,3250, RelatedList.tsx:1607, RecordDetailPanel.tsx:167, sectionFields.ts:321, ObjectGallery.tsx:367, fieldEnrichment.ts:231, useColumnSummary.ts:492 — built into columnHints and never read by formatSummaryLabel), and the one WRITER ObjectFieldInspector.tsx:917-919 (patchDef({ precision: v }), comment :287 calls it the field-level TOTAL digit count). Faces read: CurrencyField.tsx:80 fractionDigits = currency ? currencyFractionDigits(currency) : 2 (neither key); NumberCellRenderer index.tsx:772 numField.scale; PercentCellRenderer :852 scale ?? 0; formatCurrency(value, currency?, locale?) (:472) takes no width and is what the grid footer's currency arm calls (useColumnSummary.ts currency arm; percent arm column?.scale ?? 0); ObjectMetricWidget.tsx resolves only the currency (resolveFieldCurrency) and a format by type; DetailView.tsx:1259 percentField.scale ?? 0; PercentField.tsx declaredScale = percentField?.scale; ObjectForm.tsx:874-877 copies min/max only. No apps/** hit, no export face reads it, no .js/.mjs hit. So NO face reads the field-level precision as decimal places: the stop condition did not trigger.
  • objectstack's own readers: none. \.precision\b over non-test packages/**/*.ts at head: cli/src/commands/generate.ts:1687,2789 and driver-sql/src/sql-driver.ts:18651 read numeric.precision where numeric = numericColumnFor(type) (the fixed NUMERIC_COLUMN_REPRESENTATION, currency: EXACT), never the field; -w precision over packages/*/src outside spec: comments only (objectql, formula, runtime, service-analytics read nothing). Inside spec: CurrencyConfigSchema (a different key), the spec: Field.scale accepts meaningless declarations (2.5, -1) — now that scale is enforced, malformed declarations should be refused at authoring time #8321 conversion field-malformed-scale-precision-removed (shape only), object.form.ts:189 offers the control with helpText Total digits. Storage, drivers, objectql, formula, analytics: no reader. Right.
  • No residual rule — judged right on the four axes. Business need: nothing reads the key and no producer authors it (examples: 0 currency fields with a top-level precision; the only writer is the Studio control), so a residual (precision below the minor unit) would guard the internal consistency of an inert value. Long-term fit: a total-digit coherence rule belongs beside a total-digit ENFORCEMENT (DDL or the write seam) and neither exists; measured on main's field.zod.ts: { type: 'number', precision: 1, scale: 2 } and { type: 'percent', precision: 0, scale: 3 } both parse (byte-identical at head), so a currency-only, fixed-only remnant would be the one such rule in the spec. AI-authoring safety: the plausible AI mistake (precision: 2 meaning decimals) is harmless because nothing renders from it, and the dispatch requires precision: 2 to keep parsing; the real trap is the inert key, which is a finding (③), not a rule. Startup focus: no new gate. The site comment forbids reinstating a comparison, which is the right guard against an "improvement".
  • Test block: acceptance pins bear weight; no genuine refusal pin deleted. Old block: 7 its; new: 10. The two flipped pins are the widening itself: rejects … (JPY + 2) at ['precision'] → accepted with the authored value carried; both anchors fire independently → still success: false, now asserting exactly ONE issue at ['currencyConfig','precision'] with code, path, message and the Declare \precision: 0`prescription — the genuine refusal is still pinned through theFieldSchemadoor (and separately in the untouchedContract question: should publish-time validation reject a declared currency precision that contradicts the currency's ISO 4217 digits? #7918 currencyConfig-levelblock). Agreeing values (JPY 0, KWD 3) are subsumed by the digit-class loop; no-config, dynamic and non-currency pins kept; added: USD 18 carried,ObjectSchema.createround-trip, no-coherence parity. Ablation re-run here: head's test file againstorigin/main:field.zod.ts(anchor count 2 = the re-inserted field-level call + thecurrencyConfigone) →6 failed | 24 passed (30), the six being exactly the new acceptance pins (USD 18, flipped JPY 2, digit-class loop, no-coherence parity, object door, both-keys-one-issue); restored file blob 26f8ef39== head, then30 passed (30). The pins bear weight. The #7918's relocated currency precision default is round-trip unsafe: a bare fixed-JPY currencyConfig materializes precision 2, and re-parsing that output is rejected #11423` block and the digit-table block have no hunks.
  • Docs, sentence by sentence. field-types.mdx: (a) "a currency field takes no scale: publish-time validation refuses the key" — TRUE on main: field.zod.ts if (field.type === 'currency' && field.scale !== undefined) → path ['scale'] ([finding] FieldSchema.scale on a currency field is offered to authors by the field designer, ignored by every display face, and still enforced on writes — half-live in the direction that surprises #19629), pinned in field-currency-scale-refused.test.ts; the deleted scale | Decimal places row was therefore false on main, and deleting it is right. (b) "The field-level precision is the amount's total digit count — a DECIMAL(18,2) amount declares precision: 18 — and is never compared with the currency" — TRUE as the key's declared meaning (describe Total digits (non-negative integer), form helpText Total digits, generated references/data/field.mdx:70) and true at head for the comparison; it states a MEANING, not an enforcement — storage is the fixed numericColumnFor('currency') column and no face reads the key — so the DECIMAL(18,2) example teaches a sizing the platform does not perform. Not false; flagged in ③. (c) The currencyConfig.precision paragraph is the old rule re-keyed: TRUE for the rule at head; "Omit currencyConfig.precision to let renderers derive the width from the currency itself" implies a reader for that key, and objectui at the pin reads currencyConfig.precision nowhere (one comment hit, CurrencyField.tsx:74) — pre-existing wording, [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992's region, flagged in ③. validation-rules.mdx:155: TRUE (the field-level key is not compared). The unchanged "Precision defaults to 2 decimal places" sentence is the currencyConfig default, pre-existing.
  • Changeset body claims. "Nothing that parsed before is refused now" and "output of every previously accepted field is byte-identical": TRUE (issue-only removal). "scale on a currency field is still refused": TRUE (above).

② Semver level

  • @objectstack/spec: minor with Clause-②: yes (widening) in the changeset and Clause-②: yes in the PR body. Both spellings are the legal pair in scripts/pm/clause2-line.mjs (yes / yes (widening) — "both take at least minor"). check-changeset-no-major.mjs's level axis (clause ② declared ⇒ at least one moved package graded minor or above) is satisfied by spec: minor; a fix( type may raise a bump but never lower it, so minor stands. The Check Changeset check-run on this head is success. Level and wording are right for a widening that removes a ruling-installed refusal: the accept set grows, nothing is renamed or retired, no consumer migrates.
  • ADR-0087 entry rightly absent. check-adr-0087-registration.mjs breakingDeclaration() fires on major, **BREAKING, a ! summary, or a Clause-② arm reading narrowing — its own docblock: "A widening is not a break". Precedent: .changeset/19320-percent-storage-scale-derivation.md (Clause-②: yes (widening), spec+objectql minor, 36 cells refused→accepted, no ADR-0087 marker) — the precedent ruling 乙 item 4 itself names. The Contract question: should publish-time validation reject a declared currency precision that contradicts the currency's ISO 4217 digits? #7918 field-level refusal never had a ledger entry (7918 occurs nowhere under packages/spec/src/migrations), so there is no entry to retire or amend; the #7918 changeset is not in stock (released in v17). The removed refusal is recorded where a widening is recorded: the changeset body and the site comment.

③ Boundary flags

Implemented-by: claude/issue-20011-currency-field-precision-anchor
Reviewed-by: session_01Rjy9MeetSfq34PKn81CRiN

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 09:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 84156c7 Sep 27, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20011-currency-field-precision-anchor branch September 27, 2026 10:03
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… promises no default symbol (objectstack-ai#20045) (objectstack-ai#20223)

Fixes objectstack-ai#20045
Clause-②: yes

`Clause-②: yes` is the claim's line (comment `5854472496`), copied as it
stands. The changeset carries `Clause-②: yes (narrowing)`, because
AGENTS.md makes a narrowing BREAKING and `check:adr-0087-registration`
reads the arm there. ⚠ This diff widens nothing: it only refuses a shape
that parsed before. Read against `scripts/pm/clause2-line.mjs`
(「本卡放宽接受集或扩大公开面吗」), the value that fits is `no (narrowing)`, which is
ruling B's own declaration on objectstack-ai#19629. The dev report raises this as an
open question. Neither line was changed on the dev's own judgement.

Branch `claude/issue-20045-inline-currency-column-scale`, base
`1c8b320a89`, merged with `origin/main` `b09ce67870` as `4a6e8ed61a`
through `scripts/pm/os-regen-merge.sh`. Every reading below was taken at
head `4a6e8ed61a` unless it says otherwise.

## What changes

Triage direction: comment `5825687357`, option A. Triage read the card
as inherited from ruling B `5791803339` on objectstack-ai#19629 (`scale` retired from
the currency field) and ruling 乙 `5805782503` on objectstack-ai#19910 (「a currency's
ISO 4217 minor unit decides its display」).

1. `InlineGridColumnSchema` (`packages/spec/src/data/field.zod.ts`) now
refuses an authored `scale` on a column that declares `type:
'currency'`. The refusal is one custom issue at the column's `scale`,
and it applies to any value, `scale: 0` included, computed or not. The
message carries ruling B's shape:
- first sentence: "`scale` is not valid on a `currency` inline grid
column — delete the key." This is the field refusal's first sentence
with only the subject swapped;
- remedy: the currency's ISO 4217 minor unit (2 for USD, 0 for JPY, 3
for KWD) decides how the cell displays the amount and the width a
computed amount is rounded to. It names no other key.
   - No alias and no grace window.
- `scale` on a `number` column, and on a column that declares no `type`,
is unchanged.
2. The `prefix` describe no longer says 「(default '¥')」. It now reads:
the symbol replaces the resolved currency's own symbol; there is no
default; it replaces the symbol only. `prefix` is still accepted. The
`scale` describe drops "numeric/currency" and names the currency
refusal.
3. ADR-0087: the D3 semantic entry
`18.inline-grid-column-currency-scale-refused` is added, and
`registry.ts` was regenerated with `gen:migration-registry`.
4. The changeset makes `@objectstack/spec` `minor`, with a BREAKING
banner, a FROM → TO table and the `registered` disposition marker.
5. `dropped-refinements.baseline.json` gains the new refinement's rows,
exactly as the build's ratchet printed them: `data/InlineGridColumn`
(root) plus one `…inlineColumns.element` site on each of the 12
published schemas that embed the column. The header totals moved with
the body: 208 schemas, 590 sites after the merge.
6. `content/docs/references/data/field.mdx` was regenerated
(`check:generated --fix`, which proved only `check:docs` stale).

## ADR-0087 disposition: why D3 only

- **A D3 entry is owed.** Ruling B on objectstack-ai#17152 (`5615360777`, restated
`5634031140`) says 「D3 每家族一条,D2 可无损表达者亦然;D2 只承载数据修复」.
- **No D2 conversion.** The card inherits ruling B on objectstack-ai#19629, which says
no alias and no grace window. Its own entry,
`field-currency-scale-refused`, is "NOT mechanically converted,
deliberately". A load-path conversion that dropped the key would accept
it on every load, which is the grace window the ruling refused. This
entry follows that precedent.
- **No `retired-keys/` row.** The key is not removed. It stays in the
walked shape and stays live on `number` columns, and a
`RETIRED_KEYS_BY_MAJOR` row naming a live key reds gate (b2).

## Premise, measured

- On `1c8b320a89`, `InlineGridColumnSchema` was a bare `strictObject`
with no refinement, and `scale` was declared for a "computed
numeric/currency result".
- The ablation below is the executable half of that reading. With the
new refinement short-circuited, all six refusal pins go red, because
every currency-plus-`scale` shape parses again.
- The objectui side was read by content:
- at objectui `origin/main` `25c7d58`, `GridField.tsx` has `function
currencyAdornment` ×1 and `function currencyWidth` ×1, and `c.prefix ||
'¥'` ×0;
- at this repo's `.objectui-sha` pin `f8a9d0fb0596`, it has `c.prefix ||
'¥'` ×2 and the `currency ? 2` default ×1 (see Acceptance notes).

## Mechanism hypothesis H1, measured

- `InlineGridColumnSchema` was at `:895`, a `strictObject`, with
`prefix` at `:924` and `scale` at `:938`. **Confirmed.**
- "Reuse the refusal sentence or the shared helper" was **partly
falsified**. No helper exists: ruling B's sentence is a string literal
inside `FieldSchema`'s `superRefine` (`:2295–2307` on base). That block
is outside this claim's file surface. So the column carries the sentence
and remedy in its own constant, `INLINE_GRID_CURRENCY_SCALE_REFUSAL`.
- A parity pin keeps the two refusals from drifting apart. It reads both
refusals and asserts that the first sentences match (subject swapped),
that both carry the same minor-unit clause, and that neither names
`currencyConfig` or `precision`.
- `FieldSchema`'s currency-precision anchor was not touched. It changed
on `main` during this run (objectstack-ai#20011, PR objectstack-ai#20209). It merged cleanly and is
disjoint from this diff.

## Authored instances (H2), census on `1c8b320a89`

- `git grep inlineColumns` over the whole tree gives 30 files. Exactly
**one** authored block:
`examples/app-showcase/src/data/objects/invoice.object.ts`, with 7
identity-only `{ name }` columns, 0 declaring `type` and 0 declaring
`scale`. This is the control: the same instrument counts 7 column
entries in that block.
- Platform objects, `skills/**`, `content/docs/**` prose and examples,
and JSON/YAML fixtures: 0 inline grid columns.
- Objectui at the pin: 0 authored `inlineColumns` fixtures. Its only
hits are the two renderer files.
- Test fixtures: 1 carried `scale: 2` on a currency column
(`inline-related-columns.test.ts`, the "every renderer-read key" case).
It was re-judged: `scale` was deleted there, and the key stays covered
by the identity-only computed `amount` column in the same fixture.
- Deployed metadata: **NOT MEASURED**, because no instrument reaches it.

## Tests, at head `4a6e8ed61a`

All heavy runs went through `scripts/pm/os-verify-lock.sh`, and each
verdict below is read from its VERDICT line.

- `@objectstack/spec` build: exit 0.
- `check:generated`: "All 15 generated artifacts are up to date".
- spec `vitest --project local`: **542 files passed; 15932 passed, 2
todo**.
- spec `typecheck`: exit 0 (`check:test-typecheck: OK — 53 file(s) / 255
error(s) / 142 pinned signature(s) held`).
- spec `vitest --project repo`: 32 files / 586 passed, at `4dbb609b1a`
before the merge. It was not re-run on the merged head, because the run
took 15m23s on a box under load 30 to 49. It is declared to CI.
- The new file `inline-grid-column-currency-scale-refused.test.ts`
covers:
- refusal through `FieldSchema`, `ObjectSchema` and the standalone
column, located at `…inlineColumns[i].scale`, with `code: 'custom'`;
  - every value, computed or not;
- controls: a `number` column, an untyped column, `prefix` accepted, and
the remedy parsing and re-parsing unchanged;
  - field/column shape parity;
  - the two describes;
  - the D3 entry being registered.

**Ablation**, from committed state `aa4b6ca14f`, run with
`scripts/ablation-replace.mjs`:
- The anchor `if (column.type === 'currency' && column.scale !==
undefined) {` was hit ×1 and went ×1 → ×0. The replacement `if (false &&
…` went ×0 → ×1. The blob changed from `147220988d04` to `ddf1ebbf0437`.
- Result: **6 failed | 22 passed** (28). All six refusal/parity pins
went red, and the controls stayed green, which is the expected
direction.
- Restore: blob `147220988d04` == HEAD, and `git diff HEAD` is empty.
- The subject is imported from `src` (`./field.zod`), so no dist rebuild
was involved.

**Gates.** `dispatch-gates.mjs --commands` was re-derived on
`4a6e8ed61a` and gave 111 families, the same list as before the merge.
`--ran` reconciliation: "111 derived famil(ies) accounted for — 109 run,
2 NOT-MEASURED".
- `NOT MEASURED: check:dual-build-cjs-loads`, reason: PREREQUISITE NOT
MET. 33 packages have no `dist/`, and only a full-repo build satisfies
that. This diff changes no entry point, export map or build config.
- `NOT MEASURED: check:type-check-debt`. Before the merge it exited 3
(`@objectstack/driver-turso` unbuilt). On the merged head, with that
package built, `--re-measure` began a workspace-closure `turbo build`
outside the verify lock. It was stopped by its recorded PID after 9
minutes, with no verdict. This diff changes no exported type:
`check:api-surface` is green, and `superRefine` keeps
`InlineGridColumnSchema` a `ZodObject`. It touches none of the four
DEBT-ledgered packages.

## Deviations

- **File surface.** `packages/spec/dropped-refinements.baseline.json` is
outside the claim's listed surface. The build refuses a new refinement
without it. The file is hand-edited on purpose and has no generator, and
its rows were applied exactly as the gate printed them, including the
merge-time header recount. The claim's surface names "its own
refinement", so this is reported as that refinement's mechanical
consequence, ⛔ not as scope growth.
- **Merge.** `origin/main` moved the same ledger's header totals
(objectstack-ai#20204), which caused a textual conflict. It was resolved by recounting
the merged body.

## Acceptance notes

- **Reach of the refusal.** Only a DECLARED column `type` is judged. A
column that declares no `type` gets its type from the child field when
objectui hydrates it (`deriveMasterDetail.ts` `hydrateColumns`, which
spreads the authored column). So `{ name: 'amount', scale: 2 }` over a
currency child field still parses. The column schema cannot see the
child object. Once the objectui follow-up stops reading an authored
`scale` on currency columns, that key is ignored on such a column. That
lands on the objectui follow-up card (triage note 4), not here.
- **Pinned console vs this describe.** The new `prefix` describe ("No
default") is true of objectui `main` (`25c7d58`). The console bundled at
`.objectui-sha` `f8a9d0fb0596` still falls back to `¥` and rounds a
computed currency with no `scale` to 2. The describe becomes true of the
bundled console when the pin moves past objectui PR objectstack-ai#10405. Deleting
`scale` loses no decimals under either console: the pin falls back to 2,
and `main` to the ISO minor unit. So no zero-decimal window opens.
- **Stale notes outside this surface**, recorded only (carrier: none):
- `packages/spec/liveness/field.json` → `inlineColumns.children.prefix`
/ `.scale` notes still cite `c.prefix || '¥'` and `c.scale ?? currency
default 2`;
- the `packages/spec/src/data/field-scale.ts` docblock sentence "The one
`2` that looks like a currency default belongs to an inline grid
COLUMN's rounding" describes the pre-objectui#10355 grid.
- **The objectui `GridField` follow-up** (drop the authored-`scale` read
on currency columns) is objectui's own card, per triage note 4. Nothing
in objectui changes here.

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

---------

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