Skip to content

fix(spec,objectql)!: retire scale from the currency field type — refused at parse, no longer enforced on writes - #19909

Merged
os-support-ai merged 8 commits into
mainfrom
claude/issue-19629-currency-scale-retired
Sep 25, 2026
Merged

os-support-ai merged 8 commits into
mainfrom
claude/issue-19629-currency-scale-retired

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19629

Clause-②: no (narrowing)

Executes maintainer ruling 5791803339 (batch #215 item 1, letter B, 「215 同意」): scale is retired from the currency field type. Its refusal remedy and its ADR-0087 migration entry are worded by maintainer ruling 乙 5805782503 (batch #218 item 2, 「其他同意」), item 1:

PR #19909's refusal remedy and ADR-0087 migration entry are reworded: delete scale on a currency field; the currency's ISO 4217 minor unit decides its display, and its write allowance stays unconstrained (today's contract). ⛔ No pointer to currencyConfig.precision.

The consumer half is objectstack-ai/objectui#10221 and is not in this diff: the field designer, the grid summary footer, and, under ruling 乙 item 2, the dashboard ObjectMetricWidget. B′ (enforcing a currency width on writes) was not taken and is not here.

Landing order (ruling 乙 item 2)

This PR lands after objectstack-ai/objectui#10221 has landed (widened to ObjectMetricWidget) and after this repository's .objectui-sha pin bump has moved past it. That order keeps any zero-decimal window from opening on the grid summary footer or the dashboard metric widget. At this writing the pin is 62597c588072 and objectui#10221 is open with no PR (both re-read in round 3). Holding the landing to that order is the seat's.

What changes

  1. @objectstack/spec, FieldSchema (packages/spec/src/data/field.zod.ts): scale on a type: 'currency' field is refused at parse, with one custom issue at scale (the file's per-type superRefine house pattern). No alias and no grace window. The remedy's first sentence is "scale is not valid on a currency field — delete the key." It goes on to say that the currency's ISO 4217 minor unit decides how the amount displays, and that the field's write allowance stays unconstrained. It names no other key to carry the value. The scale describe names the set the key still applies to: number, percent, rating and slider, enforced on writes, and formula, rounded. On currency it now reads "REFUSED on a currency field — delete it there".
  2. @objectstack/objectql, record validator (packages/objectql/src/validation/record-validator.ts): the max_scale branch no longer reads def.scale for currency, so the type leaves the enforced set. min, max and the finite-number check still apply to currency. percent, number, rating and slider are unchanged. Nothing else is read in its place: a currency's write allowance stays unconstrained.
  3. ADR-0087 semantic entry field-currency-scale-refused (packages/spec/src/migrations/entries/semantic/18.field-currency-scale-refused.ts). Its replacement is "DELETE the key — that is the whole migration", and it says nothing replaces the key. It is deliberately not a mechanical conversion: a conversion that dropped the key would accept it on every load, which is the grace window ruling B refused. registry.ts is regenerated with gen:migration-registry, never hand-edited.
  4. Shipped examples that the refusal would reject. scale: 2 was deleted from each, and nothing was added:
    • examples/app-crm/src/objects/account.object.ts, opportunity.object.ts, opportunity-line-item.object.ts (×2)
    • examples/app-showcase/src/data/objects/account.object.ts, client-brief.object.ts, expense-report.object.ts, external/customer.object.ts, external/order.object.ts, field-zoo.object.ts, invoice.object.ts (×3), project.object.ts (×2)
  5. Documentation examples. scale: 2 was deleted from 13 currency examples in content/docs/concepts/architecture.mdx (×2), concepts/metadata-driven.mdx (×2), data-modeling/fields.mdx, data-modeling/objects.mdx, data-modeling/schema-design.mdx, protocol/kernel/plugin-spec.mdx, protocol/objectql/schema.mdx (×4) and protocol/objectql/types.mdx. The schema.mdx property table's scale row no longer lists currency. It now says "Refused on currency — delete it there".
  6. Generated, never hand-edited: content/docs/references/data/field.mdx, content/docs/references/data/object.mdx and content/docs/references/system/migration.mdx (the scale describe). authorable-surface, api-surface, json-schema.manifest, spec-changes.json and the upgrade guide did not move, because no key or export was added or removed. The nine *.metadata-forms.generated.ts i18n bundles did not move either: round 3 changes a visibleWhen, not a label or help text, and those bundles carry only label and help text.
  7. packages/spec/src/data/field-scale.ts: the docblock's currency bullet records the retirement and ruling 乙's remedy. It no longer names CurrencyConfigSchema.precision as the money faces' override; that claim was false at the pin.
  8. packages/spec/src/data/object.form.ts (round 3): the object designer's quick-add fields grid, objectForm, is the form METADATA_FORM_REGISTRY serves for the object metadata type. Its scale row's visibleWhen was "data.type in ['number','currency','percent']" and is now "data.type in ['number','percent']", with a one-line comment citing this card and ruling 5791803339 B. The precision, min and max rows are unchanged.

Measured first, on origin/main 1f89ba0d70 (built dist)

  • A currency field with scale: 2 parsed: FieldSchema.safeParse succeeded. Control: the same with type: 'number' also succeeded.
  • The record validator refused a currency write over scale: scale: 2, write 1.234 → max_scale {"scale":2,"actual":3}. A currency field without scale accepted 1.23456.
  • max_scale read def.scale on all four controls. Each was given scale: 2 and the write 1.23456. number, rating and slider answered {scale:2, actual:5}. percent answered {scale:4, actual:5}, because of the fraction-storage derivation.
  • Population census, a TypeScript AST sweep over every tracked .ts/.tsx/.mjs/.js file (7,036 files): 15 example sites, all listed above, and 7 test fixtures, listed below. A JSON walk over 556 tracked .json files found 0 currency-typed objects with scale. A windowed scan of 831 .md/.mdx files found the 13 documentation examples. The one surviving AST hit is packages/spec/src/data/inline-related-columns.test.ts:103. It is InlineGridColumnSchema.scale, a different schema that the ruling does not cover.

Readings the round measured, and where each went

  1. currencyConfig.precision has no reader. No face in the pinned console (.objectui-sha 62597c588072) reads it, and no non-test code in this repository does. The cell uses the currency's ISO 4217 minor unit, and the edit widget reads the field-level precision. → Ruling 乙 item 1: the remedy and the migration entry no longer point to that key. Two findings follow from ruling 乙 item 3: currencyConfig.precision is declared and read by nothing, and objectui's CurrencyField reads the field-level precision as decimal places. Both are filed by the domain:spec seat, as [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 and [finding] CurrencyField renders the field-level precision (a total-digit count in the spec) as the number of decimal places — filed on objectstack ruling 乙 (objectstack-ai/objectstack#19910) objectui#10276, ⛔ not riders here.
  2. Ruling B's control reading undercounted the examples. Fifteen Field.currency declarations in examples/ declared scale: 2. → Corrected on the card by 5805294161.
  3. A fourth face. The dashboard ObjectMetricWidget's currency arm reads valueFieldDef.scale ?? 0. → Ruling 乙 item 2: objectui#10221 is widened to it and lands first (see the landing order above).
  4. Direction of the validator half. It moves writes from refused to accepted. → Ruling 乙 item 4: Clause-②: no (narrowing) stays as ruled. The widening reading from the finding(spec,objectql): ruling B's percent storage derivation is NOT live — max_scale has no percent arm and the percentScaleOf docblock is silent about scale #19320 precedent is noted and ⛔ not re-declared.

Round 3, on top of 40ef043003

Why. The at-tier contract-review record 5818558748 on this PR (head 40ef043003) read VERDICT: FAIL with one required fix: packages/spec/src/data/object.form.ts:190 still offered scale on a currency row of the object designer's fields grid, the form registered in packages/spec/src/system/metadata-form-registry.ts. After this PR an author using that form is shown a scale input on a currency field and is refused at publish, which is the offer-vs-door shape this retirement removes.

  • d7740c324e: the fix exactly as the record states it. The scale row's visibleWhen is now "data.type in ['number','percent']", with the one-line comment. The row was not widened to any other type, and the precision, min and max rows were not touched. The same commit adds the offer-side pin and one changeset sentence (both below). origin/main was not merged: the PR reads mergeable_state: clean, and no file main changed since the merge base c8399867b8 is a file this branch changes (comm of the two name lists is empty), so CI's merge ref needs nothing from a merge here.

Sweep of packages/spec/src/** for any other surface offering scale on currency. All 17 *.form.ts files were read for scale rows, and every non-test source file naming both scale and currency was read at the hit.

file:line (HEAD d7740c324e) what it is changed?
data/object.form.ts:191 the objectForm fields-grid scale row (it was :190) yes, the record's fix
data/field.form.ts:79 the fieldForm scale row, "data.type == 'number'" no; it does not offer the key on currency (the record's own control)
data/field.zod.ts:1235-1236 FieldSchema.scale and its describe, "REFUSED on a currency field" no; that is the door, from round 2
data/field.zod.ts:1025 FieldSchema aliases decimals / decimalPlaces → scale no; on currency the renamed key lands on the same refusal (acceptance note below)
data/field.zod.ts:420 CurrencyConfigSchema alias scale → precision, inside currencyConfig no; a different schema, pre-existing, and the surface of #19992 (acceptance note below)
data/field.zod.ts:931 InlineGridColumnSchema.scale no; a different schema, left alone as instructed
ui/view.zod.ts:3338 FormFieldSchema.scale, a form-view row's widget override with no type gate by design (a form row usually omits type) no; a different schema, and its describe names no field type
ui/view.zod.ts:1600 the timeline view's scale (hour … year) no; unrelated
ui/bulk-action.zod.ts:157 scale in BULK_PARAM_WIDGET_CONFIG_KEYS, a list of keys bulk params REFUSE no; a refusal list, not an offer
data/field-scale.ts:101 ABSENT_SCALE_BY_TYPE, whose one row is percent no; there is no currency row
data/numeric-column-representation.ts:49-52 a dated measurement docblock that names a currency field with scale: 2 refused at the write seam no; a record of a reading, not an offer (acceptance note below)
studio/** designer hints 0 currency hits
packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts (outside packages/spec/src) the generated form bundles; fields.scale carries only its label and help text (en :147-150) no; no predicate lives there, and check:i18n reads all nine packages' bundles in sync

A quoted-exact git grep -n -F "['number','currency','percent']" over the whole tree now answers one line, the precision row at object.form.ts:189, which the record says to leave. Before the fix it answered that row and the scale row.

The new pin (packages/spec/src/data/field-currency-scale-refused.test.ts, final block). It evaluates each row's visibleWhen the way form-delete-behavior-options.test.ts already does: a fail-closed reader of the data.type spellings these forms use (== and in [...], joined by ||) that throws on anything else, so a predicate it cannot read fails the test instead of being assumed visible or hidden.

  • CONTROLS (lit): the walk over every form in METADATA_FORM_REGISTRY finds exactly two scale rows, field:scale and object:fields.scale. METADATA_FORM_REGISTRY.object is objectForm. The reader lights on a row that does name currency.
  • Firing case: the objectForm scale row is not offered when data.type is currency.
  • Dark controls: it is still offered for number and percent, and the door agrees on both types (FieldSchema.safeParse with scale: 2 succeeds).
  • Class guard: no registered form offers scale on a currency field.

Old-row pins. Nothing pinned the old row: before the fix, the quoted-exact git grep above found the predicate only in object.form.ts itself, and none of the tests that read objectForm or METADATA_FORM_REGISTRY mention scale. There was nothing to reverse.

Round 2, on top of 7c0a33c6ad

  • 07c9be36bf: a merge of origin/main c8399867b8, run through scripts/pm/os-regen-merge.sh. registry.ts text-merged, and gen:migration-registry then wrote no change. check:generated read "All 15 generated artifacts are up to date".
    • Main changed none of the three reference pages this branch changes, so the script kept the branch's bytes for them. The three reference pages main did change (api/package-api.mdx, security/permission.mdx, system/translation.mdx) are byte-identical to origin/main (git diff empty).
    • Quoted-exact git grep survival checks:
      • Each of the 7 semantic entries main added since the merge base appears exactly once in both origin/main's and HEAD's registry.ts: admin-scope-business-unit-blank-refused, filter-equality-array-comparand-refused, flow-predicate-slot-blank-string-refused, package-api-contracts-unmounted-entries-retired, rls-predicate-array-comparand-refused, translation-per-app-settings-platform-only and view-filter-rule-absent-value-refused.
      • Main's added reference line "Required and non-blank: an empty or whitespace-only value names no business unit" appears 2 times in permission.mdx on both sides.
      • Main's deleted line "POST /api/v1/packages/upgrade" appears 0 times on both sides.
  • e2c5077421: the rewording to ruling 乙, across the refusal message, the describe, the migration entry (with registry.ts regenerated), the validator's comments, the field-scale.ts docblock, the schema.mdx row, the changeset and the spec pins. What is refused and what is accepted did not change.
  • 40ef043003: gen:docs regenerated the three reference pages. A later gen:schema && gen:docs produced no further diff, and check:docs read "225 generated files in sync with packages/spec".

Tests, on HEAD d7740c324e

Every command went through scripts/pm/os-verify-lock.sh. Each count is the runner's own summary line.

  • Build: turbo run build over ./packages/*, ./packages/*/* and ./examples/* gave 73 successful, 73 total.
  • @objectstack/spec: vitest run --project local gave 532 files, 15667 passed, 2 todo (round 2: 15663; the 4 new offer-side tests). --project repo gave 35 files, 602 passed.
  • field-currency-scale-refused.test.ts alone: 12 passed (8 from round 2, 4 new).
  • @objectstack/spec typecheck exited 0. check:test-typecheck held 53 files, 255 errors and 142 pinned signatures, the same as rounds 1 and 2. check:type-check-coverage exited 0, so the package's tsconfig reaches the edited test file.
  • Consumers that read the registered forms: @objectstack/platform-objects translation suites gave 6 files, 137 passed; @objectstack/lint validate-predicate-path-refs.test.ts gave 1 file, 54 passed; @objectstack/metadata-protocol protocol.meta-types-* gave 3 files, 38 passed.
  • NOT re-run this round: @objectstack/objectql (on 40ef043003: 309 files, 5196 passed; 1 file, 5 passed; typecheck exit 0 with 40 files, 234 errors and 65 pinned signatures held), @objectstack/example-crm, @objectstack/example-showcase, and round 1's platform-objects, metadata-core, driver-sql and service-automation suites. No file in those packages changed since 40ef043003. They are declared to CI.

Reverse verification

All legs are one-shot proofs run from the committed state, each with a trap on EXIT/INT/TERM. No rebuild was needed, because the test file resolves ./object.form, ./field.zod and ../system/metadata-form-registry through relative src imports.

Round 3, the offer. scripts/ablation-replace.mjs restored the old row, visibleWhen: "data.type in ['number','currency','percent']", on object.form.ts. The landing was proved on disk: new-row anchor 1 → 0, old row 0 → 1, blob 6e87b93283 → 295afbe69f. Then field-currency-scale-refused.test.ts ran: 2 failed | 10 passed. The two failures are the firing case ("expected true to be false") and the class guard (it received object:fields.scale where it expected none). The lit CONTROLS, the number and percent dark controls and all 8 round-2 tests stayed green. Restored: blob 6e87b93283 == HEAD, git diff HEAD empty, and git status --porcelain empty.

Round 2's two legs, on 40ef043003:

  • The ruled wording. field.zod.ts was restored to its round-1 blob 0c24baa382, which carries the old remedy naming the other key. Main changed nothing in this file across the merge, so that blob differs from HEAD only by the round-2 rewording. The landing was proved by blob hash, with the old-remedy anchor ×1 and the new one ×0. Then field-currency-scale-refused.test.ts ran: 4 failed | 4 passed. The three message pins went red on the first-sentence assertion, and the describe pin went red on its new clause. The firing control (every declared value refused) and the three dark controls stayed green. Restored: blob d6ada97894 == HEAD, and git diff HEAD is empty.
  • The absence half. scripts/ablation-replace.mjs appended "Move the value to currencyConfig.precision instead." to the new message. Anchor 1 → 0, blob d6ada97894 → 017fdba420. Result: 3 failed | 5 passed. All three failures read "not to match /currencyConfig|precision/", and the describe pin and every control stayed green. Restored: blob == HEAD, and git diff HEAD is empty.

Test pins of the old remedy, reversed

A repo-wide grep finds the old remedy pinned in one file only, packages/spec/src/data/field-currency-scale-refused.test.ts. It had a message toContain on the other key and a control named "the remedy target parses". Here is what replaces them:

  • One helper asserts the message's first sentence verbatim, the two ruled clauses, and that the message names neither currencyConfig nor precision. It runs on all three firing paths: the designer shape, the shape beside a currencyConfig block, and the Field.currency() + ObjectSchema path.
  • The control is now "following the remedy parses". It takes each refused shape, deletes scale, adds nothing, and the result is accepted.
  • The describe pin asserts the new clause and the absence of currencyConfig.
  • packages/spec/src/data/currency-precision-iso4217.test.ts:293 names that key only as CurrencyConfigSchema's ISO-contradiction issue path. It is not a remedy pin and is unchanged.

currencyConfig hits that remain (git grep on HEAD 40ef043003, unchanged at d7740c324e)

  • In the branch's added lines (c8399867b8..HEAD): 7 hits. None names that key as a remedy or as a decimal-places carrier.
    • The changeset's FROM → TO row 2 shows a field that declares a currencyConfig block. Its fix is still the deletion.
    • field-zoo.object.ts:53 is in the diff only because scale: 2 was deleted from that line. Its currencyConfig: { …, precision: 2 } is origin/main's, unchanged.
    • field-currency-scale-refused.test.ts has 5 hits: two absence assertions, one test title, and two fixtures with a currencyConfig block and no precision (the firing case and the remedy-follow control). Round 3 added none.
  • In the touched files, pre-existing (the same count on c8399867b8 and HEAD):
    • fields.mdx 1, types.mdx 2 and the three reference pages 2 each. The reference hits are generated from CurrencyConfigSchema.
    • Showcase account.object.ts 2, field-zoo.object.ts 1.
    • field.test.ts 34 and field.zod.ts 10: the CurrencyConfigSchema definition and tests, the ISO check, and the currency-is-not-a-field-key refusal.
    • registry.ts 1, in another entry.
    • These stay because this PR did not write them. The key's own disposition is ruling 乙 item 3's first finding, for the domain:spec seat.

Test fixtures re-judged (round 1)

These fixtures pinned a currency field with scale, or max_scale on currency:

Changeset

.changeset/19629-currency-scale-retired.md sets @objectstack/spec: minor and @objectstack/objectql: minor, with a BREAKING banner. Per AGENTS.md, Clause-②: no (narrowing) is breaking, and check-changeset-no-major refuses major. Round 2 reworded it to ruling 乙:

  • Its remedy sentence and FROM → TO table now prescribe deletion only.
  • Its write sentence says the allowance stays unconstrained.
  • Its console bullet says both faces derive from the currency in the console this release bundles, because of the landing order above.

Round 3 added one sentence to its @objectstack/spec paragraph, because the form change is visible in Studio: "Studio's object editor no longer offers scale on a currency field: the fields grid of the objectForm this package registers in METADATA_FORM_REGISTRY now shows it only for number and percent." It names no key to carry the value, so it holds under ruling 乙.

It carries the ADR-0087 marker registered field-currency-scale-refused. check-adr-0087-registration reads "[BREAKING+bang+clause-②-narrowing] registered field-currency-scale-refused". The test-only packages (driver-sql, metadata-core, service-automation) ship nothing that changed, and the examples are private.

Acceptance notes

Gates, on HEAD d7740c324e

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 120 families from this worktree. Every one was run with its exit code captured before any pipe, and all 120 exited 0. The --ran reconciliation read: "120 derived famil(ies) accounted for — 120 run, 0 NOT-MEASURED (a DERIVED zero — all 120 recorded an exit code and none of them is 3)". The derivation warned of a stale tree: origin/main e8f163fc3a is 23 commits past the merge base, and 18 derivation inputs changed in that range (among them .github/workflows/lint.yml and package.json). CI runs the PR's merge ref with the current copies.
  • pnpm --filter @objectstack/spec check:generated: "All 15 generated artifacts are up to date".
  • check:i18n: "OK (9 package(s) — all bundles in sync, no undeclared authoring keys)". check:nul-bytes: OK, no raw ASCII control bytes.
  • check-changeset-no-major: no major bump. The level axis reads this PR body, so it is NOT MEASURED locally.
  • CI on d7740c324e, read at 2026-09-24T18:25Z: 35 check-runs, 33 success and 2 skipped (Console Pin Gate, Packed-tarball smoke (opt-in)), none failed. All seven required contexts and Check Changeset read success.
  • Lint was narrowed to the diff and was not run repo-wide. eslint --no-inline-config --format json over the 25 changed .ts/.mjs files (c8399867b8..HEAD, round 3 adds object.form.ts) reported 25 files, 0 errors, 0 warnings and no ignore notices. The narrowing holds because eslint.config.mjs never enables type-aware linting (its own comment at lines 326-328: no parserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict. The full pnpm lint belongs to CI.

Generated by Claude Code

A currency field carrying `scale` is refused at parse with a remedy naming
`currencyConfig.precision`, and the record validator's `max_scale` branch
stops reading `scale` for `currency` (number / percent / rating / slider
unchanged). Adds the ADR-0087 semantic entry `field-currency-scale-refused`
and deletes `scale: 2` from the shipped example and documentation currency
fields that the refusal would otherwise reject.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 23, 2026
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 3 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/field-scale.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/field-scale.ts) — pages documenting those are invisible to this run
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • 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 — 137 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 e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json e8f163fc3a62cc6c65f91d2197f7b516e3a2c90b

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

The refusal of `scale` on a `currency` field, its ADR-0087 semantic entry
`field-currency-scale-refused`, the `scale` describe, the changeset and the
validator's comments now prescribe one thing: delete the key. A currency's
decimal places are the currency's: its ISO 4217 minor unit decides its
display, and its write allowance stays unconstrained (today's contract). No
other key is named as a place to move the value.

What is refused and what is accepted do not change. The spec pins now hold
the ruled wording on the message (first sentence, both ruled clauses) and
the absence of any other key, beside the firing and dark controls. The
registry is regenerated with gen:migration-registry.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Sep 24, 2026
Generated by gen:docs from the rebuilt spec; the only change is the
`scale` row's currency clause on the three pages that render FieldSchema.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
The object designer's quick-add fields grid (`objectForm`, the registered
`object` metadata form) still showed its `scale` input for `currency`, while
`FieldSchema` now refuses that key on a currency field at parse. Scope the
row's `visibleWhen` to `number` and `percent`; the `precision`, `min` and
`max` rows are unchanged, and no label moves, so the i18n bundles do not.

Pin the offer beside the door: every registered form's `scale` rows are read
with a fail-closed `data.type` predicate reader, lit by an exact roster of
the two `scale` rows the forms declare. The currency row is not offered;
`number` and `percent` still are, and their parse still accepts the key.

The changeset gains one sentence for the Studio-visible half.

Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing-order note (ruling 乙 item 2): the first half is met. 2026-09-24T20:10Z

objectstack-ai/objectui#10221 landed as objectui 0651e7ab4 (PR objectstack-ai/objectui#10348, merged 2026-09-24T20:03Z), widened to ObjectMetricWidget as ruled. With a currency resolved, the grid summary footer and the metric tile now format with the list cell's formatCurrency, on the currency's ISO 4217 minor unit. Neither reads scale or precision on a currency, and the designer no longer offers Scale on currency.

The second half is still open: this repository's .objectui-sha is 62597c588 (bumped by #19832), which does NOT contain 0651e7ab4 (checked with git merge-base --is-ancestor). This PR still waits on a console pin bump past 0651e7ab4.

Carrier note for the objectui spec-pin bump that eventually ships this PR's refusal. It was recorded at objectui#10221's acceptance and is ⛔ not this PR's to fix. A stored currency field def that still carries scale, including one switched from number to currency (the designer keeps every key), no longer has a control in the objectui designer to clear the key. Under the pinned @objectstack/spec 17.4.0 that is harmless. Once objectui installs a spec with this PR's parse refusal, saving such a draft is refused with nothing on screen to fix it. objectui#4644 is the precedent: a type-conditional strip on the designer's write path. Whoever bumps objectui's spec pin past this PR owns that, and the domain:ui seat will pick it up there.

domain:ui seat #4, session_01BP8CMtACxTdLjqR6rhd33C.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: d7740c324e92789285785ed3632148d078e6b8cf

① Derived judgments

Round-3 delta is exactly the required fix, and nothing else moved. git show d7740c324e touches three files: packages/spec/src/data/object.form.ts (+2/−1), packages/spec/src/data/field-currency-scale-refused.test.ts (+109), .changeset/19629-currency-scale-retired.md (+1/−1). git diff 40ef043003..HEAD --stat confirms no other file changed between the FAILed head and this one, so every finding the prior review marked right (field.zod.ts:2281-2292 refusal scoped to currency only; ruling 乙 wording with no currencyConfig/precision; record-validator.ts:811 t !== 'currency' && gate with percent/number/rating/slider untouched; ADR-0087 entry byte-identical in registry.ts:8333-8375; 15 example sites and 13 doc examples losing exactly scale: 2; generated reference docs matching the describe) stands unchanged on the full diff c8399867b8...HEAD (37 files, +573/−63).

Blocking item fixed — right. object.form.ts:190-191: the scale row's visibleWhen is now "data.type in ['number','percent']", with a one-line #19629 (ruling 5791803339 B) comment. helpText: 'Decimal places' and type: 'number' are unchanged; the min/max rows (:187-188, still include currency — correct, the validator still applies them to currency) and the precision row (:189, #7918, out of scope) are untouched. The row sits in the field: 'fields' repeater (:59, nested fields: at :71), i.e. the Studio object editor's quick-add grid registered at metadata-form-registry.ts:68 as object.

Sweep of every other shipped form surface — clean. grep scale packages/spec/src/**/*.form.ts returns only field.form.ts:79 ("data.type == 'number'", pre-existing, no currency) and the fixed object.form.ts:191. All 16 registry entries (metadata-form-registry.ts:68-92, incl. system/email-template.form.ts) are under that glob. No other packages/** or apps/** non-test source pairs scale with currency in an offering position; the remaining hits are comments (numeric-column-representation.ts:50 describes the no-scale write allowance, which is now the only currency case; field-scale.ts:68-85 already states the retirement).

i18n bundles — need not move, and did not. The extractor keys metadataForms.<type>.fields.* on label/helpText only; packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts:147-149 carries "fields.scale": { helpText: "Decimal places" } unchanged, and no *.generated.ts appears anywhere in the full diff stat. The predicate string is not extracted (the "fields.visibleWhen" key at :219 is the form field named visibleWhen, not this row's predicate).

New pin test — sound, no new defect. The added block in field-currency-scale-refused.test.ts:146-244 walks METADATA_FORM_REGISTRY (imported from ../system/metadata-form-registry, which is the real registered map, and the test also asserts METADATA_FORM_REGISTRY.object === objectForm). rowsNamed recurses sections[].fields and each row's nested fields, so it reaches the repeater row (fields.scale). isOfferedForType is fail-closed: it reads only data.type == '…' and data.type in [...] joined by ||, throws on any other spelling or non-literal member, and treats a row with no predicate as offered (the conservative direction for this assertion). predicateSource accepts both the bare string and defineForm's { dialect, source } normalization (view.zod.ts FormViewSchema.parse). The CONTROLS case pins the exact roster ['field:scale', 'object:fields.scale'] (matches the grep) and proves the reader can say "offered" on a positive control, so the empty result in the final case is a measured zero. The number/percent case also asserts the door agrees (FieldSchema.safeParse({type, scale: 2}).success). The test imports ../system/metadata-form-registry from within data/ — a data→system→data import cycle, but only at module-evaluation of the test, and CI on this head (39 success / 7 skipped / 0 failed) shows it evaluates cleanly; not run locally because the detached worktree has no node_modules, and re-running gate families is out of scope.

Changeset delta — true. The one added sentence ("Studio's object editor no longer offers scale on a currency field: the fields grid of the objectForm this package registers in METADATA_FORM_REGISTRY now shows it only for number and percent") matches object.form.ts:191 exactly and mentions neither currencyConfig nor precision, so ruling 乙's wording rule is kept. Exactly one <!-- adr-0087: … --> marker remains.

② Semver level

Unchanged and still correct: @objectstack/spec: minor, @objectstack/objectql: minor, **BREAKING** banner, Clause-②: no (narrowing), FROM → TO table, one-line fix, one ADR-0087 marker. The form change is a shipped-metadata narrowing of the same accept set the parse refusal already narrows, so it adds no new level; the launch-window convention (minor, never major) and AGENTS.md Post-Task Checklist §3 are satisfied. The ! in the title is consistent with the banner. The "two console faces" bullet remains conditioned on the landing order (ruling 乙), which is the landing hold and not this review's concern.

③ Boundary flags

Implemented-by: claude/issue-19629-currency-scale-retired
Reviewed-by: session_01EcrTi7s5oDYPHS4Pi7h31d

VERDICT: PASS

Isolated at-tier reviewer adopted by the director seat, summon #29, on the maintainer's instruction 「执行契约复审」 · fed only the card(s), the governing rulings, the PR body, the dev's flags and the code


Generated by Claude Code

@os-support-ai
os-support-ai marked this pull request as ready for review September 25, 2026 00:38
@os-support-ai
os-support-ai added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 5b9402d Sep 25, 2026
51 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-19629-currency-scale-retired branch September 25, 2026 01:00
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…ever the field-level precision (objectui#10276) (objectstack-ai#10319)

Fixes objectstack-ai#10276

Clause-②: no — one widget stops reading a spec key with the wrong
meaning; no declared key, schema, export or accept set moves (carried
from the claim, comment 5818421884).

Dispatched implementation, `domain:ui#4` seat, session
`session_01BP8CMtACxTdLjqR6rhd33C`. Draft until the seat lands it.

## What changed

`CurrencyField` in `@object-ui/fields` took its one fraction-digit width
from `currencyField?.precision ?? (currency ?
currencyFractionDigits(currency) : 2)`. In `@objectstack/spec` the
field-level `precision` is "Total digits (non-negative integer)" — a
DECIMAL(18,2) amount is `precision: 18, scale: 2` — so a currency field
declaring `precision: 18` rendered eighteen decimal places, offered
`step="0.000000000000000001"` and rounded typed input to eighteen places
on blur.

The width is now `currency ? currencyFractionDigits(currency) : 2`: the
resolved currency's ISO 4217 minor unit, and the historical 2 when no
currency resolves (the same no-currency fallback `formatCurrency` uses
for the grid cell). Neither `precision` nor `scale` is read. The one
derived value still drives display, `step` and blur rounding, so the JPY
coupling objectui#4361 pinned holds: whole yen, `step="1"`, blur rounds
`1234.56` to `1235`.

Governing text: the maintainer ruling recorded on
objectstack-ai/objectstack#19910 (record `5805782503`, batch 218 item 2,
letter 乙), whose item 3 names this exact reading as wrong and whose
heading is "a currency's decimal places are the currency's, not a
setting"; and the ruling on objectstack-ai/objectstack#19629 (letter B,
record `5791803339`) that takes `scale` off the `currency` type.

## ⚠️ Where this departs from the triage notes — for the seat to confirm

Triage comment `5817807194` says fraction digits come "from `scale`
(when authored) or the currency's ISO 4217 digits", with the pin "only
`scale: 0` ⇒ 0 decimals". This PR does **not** read `scale`, and pins
`scale: 0` on a USD field as inert (`$1,234.50`), because:

- the 乙 ruling's heading puts the decimal places on the currency, not on
any setting; ruling B takes `scale` off the currency type and orders "no
`scale` read on currency" for the sibling footer face;
- `CurrencyFieldMetadata` in `@object-ui/types` does not declare `scale`
— measured: `{ type: 'currency', name: 'x', label: 'X', scale: 2 }`
typed as `CurrencyFieldMetadata` fails to compile against the built
`@object-ui/types` dist with TS2353;
- the widget did not read `scale` before this change either, so not
reading it moves nothing; reading it would add a reader of a key the
ruled spec change refuses at parse (objectstack-ai/objectstack#19909,
open).

The other two triage pins hold exactly as written: `precision: 18,
scale: 2` ⇒ 2 decimals; `precision: 18` alone ⇒ the currency's digits (2
for CNY and for USD). Zero decimals keep their grouping separators —
pinned on JPY (`¥1,234,567`). If the seat rules the `scale` reading
instead, the change is the one derivation line plus the two `scale`
pins.

## Measured, per the dispatch's mechanism assumptions

- **A1 held.** One derived value drives display, `step` and blur
rounding. The JPY step and blur pins stay green; the pin "USD is still
0.01, authored or derived", whose second half asserted `precision: 2` on
JPY gives a 0.01 step, is rewritten (JPY with `precision: 2` now steps
by 1; USD with `precision: 18` steps by 0.01).
- **A2 done.** The derivation comment no longer says "An AUTHORED
`precision` wins"; it states the ruled rule, cites both rulings, and
records why neither `precision`, `scale` nor `currencyConfig.precision`
is read.
- **A3 falsified — the cell is clean.** `CurrencyCellRenderer` calls
`formatCurrency(num, currency, locale)`, whose width is `isWhole ? 0 :
currency ? currencyFractionDigits(currency) : 2`; it never reads the
field. A grep of non-test `packages/*/src` and `apps/*/src` for
`precision` reads finds no other currency path that turns the
field-level `precision` into fraction digits. The grid summary footer
and `ObjectMetricWidget` read `scale ?? 0` on a currency — that is
objectui#10221's surface, not folded.
- **A4.** The pinned `@objectstack/spec` (17.4.0) declares `scale` on
`FieldSchema` for every type and still accepts it on a currency
(`FieldSchema.safeParse({ name: 'amount', type: 'currency', scale: 0 })`
succeeds); `@object-ui/types` declares `precision` but not `scale` on
`CurrencyFieldMetadata`. Nothing reads the undeclared key here, and the
gap agrees with ruling B's direction, so it is not reported as a
finding.

## File surface — three additions beyond the claim, declared

The claim named the widget, its tests and one changeset. This PR also
touches:

1. `.changeset/9568-percent-widget-reads-scale.md` — pending (not yet in
`packages/fields/CHANGELOG.md`); its closing paragraph described
CurrencyField's `precision` read as live under objectui#4361's "authored
`precision` wins", and it would ship in the same release as this change.
Corrected per the dispatch's own instruction; its declaration is
unchanged (`@object-ui/fields: minor`). The objectui#4361 text already
published in the CHANGELOG is historical record and is not touched.
2. `packages/fields/src/widgets/PercentField.tsx` — one docblock
sentence ("objectui#4361 ruled an authored `precision` wins over THAT")
that this change makes false; comment-only, same package, same gates,
and none of the 25 open PRs' file lists touches the file.
3. `content/docs/fields/currency.mdx` — the published page called
`precision` "the decimal precision" and taught `precision: 2`; this
change makes that false. It now says decimal places follow the currency
and `precision` is the total digit count, and the example drops
`precision: 2`.

## Changeset

`.changeset/10276-currency-fraction-digits-not-precision.md`,
`'@object-ui/fields': minor`. ⚠️ The dispatch named `patch`. `minor`
because the AGENTS.md version rule marks objectui's own breaking changes
`minor`, this reverses a rule the published CHANGELOG states ("an
explicitly authored `precision` still wins"), and both precedents for
the same reading on the percent faces (objectui#9295, objectui#9568)
declared `minor`. In the fixed group the two bump the same while other
`minor` changesets are pending; the seat may flip it.

## Tests and gates — all read at `8fb056145`

- `pnpm exec vitest run --maxWorkers=2 packages/fields/` (repo root,
under the shared verify lock) → `Test Files 178 passed | 1 skipped
(179)`, `Tests 3013 passed | 7 skipped (3020)`.
- `pnpm --filter @object-ui/fields type-check` (`tsc --noEmit && tsc -p
tsconfig.test.json`) → exit 0, after building the dependency closure
`pnpm --filter "@object-ui/fields^..." build`. The test file is in the
test program: `tsc -p tsconfig.test.json --listFilesOnly` lists
`CurrencyField.minorUnits.test.tsx` once among 179 test files.
- `eslint .` in `packages/fields` (the unit CI's `turbo run lint` runs)
→ 264 files, 0 errors; the touched files' warnings are all
`no-explicit-any` on lines this diff did not add (zero added lines
contain `any`).
- `check-changeset-presence` ✅ "3 source file(s) of 1 released
package(s) changed, and this change declares 1 changeset(s)";
`check-changeset-no-major` ✅; `check-changeset-fixed` ✅;
`check-changeset-claims --json` ✅ "No pending changeset names a file
this change touches"; `check-pending-changeset-literals` ✅;
`check-changeset-overwrite` reports the 9568 edit (report-only; case 2,
a deliberate prose correction, declaration unchanged).
- `check:new-line-citations` → `VERDICT new-cross-file-line-citations: 0
new citation(s)`; `check-control-bytes` ✅; `check-doc-links`,
`check-doc-fence-languages`, `check-doc-example-ids`,
`check-doc-component-types`, `check-doc-expression-carriage` → exit 0.
- `check-governed-queue-guard --test` over all six paths → NOT GOVERNED.
- NOT MEASURED: `check-doc-snippet-types` as a whole (it builds a
35-package closure; CI owns it). Narrowed instead: the edited
`currency.mdx` snippet compiles `--strict` against the built
`@object-ui/types` dist, with a control snippet in the same program that
fails (TS2353 on an undeclared key), so the types did resolve. Other
packages' tests are not owed: no export, type or spec contract moves.

## Reverse verification

Fix committed first (`3d6b06223`). A trap-guarded script restored
`CurrencyField.tsx` to the base `8b1f06619`, confirmed the mutation on
disk (old read `currencyField?.precision ??` count 1, new derivation
count 0), and ran the test file (it imports the widget by relative path,
so no build is involved): `Tests 9 failed | 15 passed (24)` — the nine
are the six `precision` pins, the no-currency `precision` pin, the step
pin and the `precision: 18` blur pin. The two `scale` pins stay green on
base, as expected: base did not read `scale` either. Restore: `git
checkout HEAD -- PATH`, then the working blob equals the `HEAD` blob
(`879bb362…`) and `git diff HEAD` is empty.

## Acceptance notes

- Out of scope, reported to the seat in the dev report, not filed here:
in objectstack's spec, the objectstack-ai/objectstack#7918 field-level
`precision` anchor in `FieldSchema`'s `superRefine` still reads a
currency field's `precision` as its display width. Measured on the
pinned 17.4.0: `{ type: 'currency', precision: 18, currencyConfig: {
currencyMode: 'fixed', defaultCurrency: 'USD' } }` is refused ("currency
USD has 2 fraction digits; `precision: 18` contradicts it", remedy
"Declare `precision: 2`"), against the key's own describe "Total digits
(non-negative integer)"; its comment's premise that objectui's
CurrencyField reads the key stops being true with this PR.
- Observation, no carrier: `ObjectForm`'s unregistered-widget fallback
derives a currency field's `step` from `scale`; after the spec refuses
`scale` on currency it resolves to `'any'`. Not reached by the
registered `CurrencyField`.
- Pre-existing and unchanged: the widget shows `$1,234.00` where the
grid cell shows `$1,234` (the cell's wholeness switch).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/objectui that referenced this pull request Sep 28, 2026
…mals come from its currency, never from scale (objectstack-ai#10221) (objectstack-ai#10348)

Fixes objectstack-ai#10221

Clause-②: no

Dispatched implementation, `domain:ui#4` seat, session
`session_01BP8CMtACxTdLjqR6rhd33C`. Draft until the seat lands it.

## What changed

Governing text: ruling 乙 on objectstack-ai/objectstack#19910 (record
`5805782503`), "a currency's decimal places are the currency's, not a
setting", which widened this card to `ObjectMetricWidget`; and ruling B
on objectstack-ai/objectstack#19629 (record `5791803339`, corrected by
`5805294161`), which retires `scale` from the `currency` type. Scope
follows 乙 over the card body's older ruling-B wording: no
`currencyConfig.precision` read, no `scale` read on a currency, no `??
N`.

1. **Grid summary footer** (`useColumnSummary`,
`@object-ui/plugin-grid`). The currency arm read `column.scale ?? 0`. It
now formats with `formatCurrency` from `@object-ui/fields`, the list
cell's own formatter (`CurrencyCellRenderer`), the same by-reference
shape the percent arm already uses with `formatPercent`. A code `Intl`
refuses keeps the arm's objectui#9294 fallback on the tenant locale;
`formatCurrency` would otherwise turn it into a locale-less `CODE
1234.50`, so the arm first checks the code's shape
(`WELL_FORMED_CURRENCY_CODE`, ECMA-402 IsWellFormedCurrencyCode: three
ASCII letters, the only test `Intl.NumberFormat` applies to a currency
before it throws).
2. **Metric tile** (`ObjectMetricWidget`,
`@object-ui/plugin-dashboard`). The currency arm built a numeral pattern
out of `valueFieldDef.scale ?? 0`. With a currency resolved, the tile
now hands `MetricWidget` the string `formatCurrency` produced (not
numeric, so `MetricWidget` shows it as given). With no currency
resolved, the pattern carries the cell's no-currency width, none for a
whole amount and two otherwise, because `MetricWidget` re-parses a
numeric-looking string (`12.50`) and would re-round a pre-formatted
plain amount. That one case is restated rather than referenced, and its
pin compares it to the cell in the same run. An authored `format` still
wins (control case).
3. **Field designer** (`ObjectFieldInspector`, `@object-ui/app-shell`).
No `Scale` control on a `currency` field (`offersScale`). `number` and
`percent` keep it. `Precision` stays on all three.

**What the cell does (measured, and what footer and tile now match).**
`formatCurrency`'s width is 0 for a whole amount, else the currency's
ISO 4217 minor unit (`currencyFractionDigits`), else 2 when no currency
resolves. So a whole USD total reads `$1,234`, as its cells do. Under
`scale: 2` the footer used to show `$1,234.00`. Sample outputs on `en`:
USD with no `scale`, `1234.5`: `$1,235` becomes `$1,234.50`. JPY with a
stale `scale: 4`: `¥1,234.5000` becomes `¥1,235`.

## Premise readings (card item 1, and the dispatch's A1 to A4)

- **Item 1: which key the `Precision` control writes on a currency
field.** The field-level `precision`, as a top-level key (`patchDef({
precision: v })`). It never writes `currencyConfig.precision`. The last
case of the new inspector suite pins it: typing 18 saves `precision: 18`
and no `currencyConfig`. This is consistent with ruling 乙 (field-level
`precision` is the total digit count). The control is left as is, and
there is no finding. Labels: `Precision` (en), `精度` (zh).
- **A1 held.** The percent arms keep reading `scale`. The new footer
suite carries a percent control case, and the objectui#9295 and
objectui#9269 suites stay green.
- **A2: measured, partly falsified.** All three faces call the same
`resolveFieldCurrency`, and footer and tile both reach the tenant
default. Their INPUTS differ, though. The footer's column hints and the
list cell's field bag on the grid's configured-column paths carry no
`currencyConfig`. The tile and the detail panel pass the whole field
def. `currencyConfig.defaultCurrency` is the only spec-legal spelling of
a fixed currency: the pinned `@objectstack/spec` 17.4.0 refuses a
top-level `currency` or `defaultCurrency` by name. Measured under a USD
tenant, on a field with `currencyConfig: { currencyMode: 'fixed',
defaultCurrency: 'JPY' }` and an amount of 1234:
  - footer: `Sum: $1,234`
  - list cell with the grid's bag: `$1,234`
  - list cell with the whole def: `¥1,234`
  - tile: `¥1,234`

Footer and configured-column cell agree with each other, and both are
wrong. Carrying `currencyConfig` into the footer alone would make them
disagree. The cell bag lives in `ObjectGrid.tsx`, which is outside this
card, so this is reported as a finding and not folded in. The pins
therefore declare the currency through the field `currency` code and the
tenant default, the spellings all three faces read today.
- **A3 held.** The one pin asserting `scale`-driven currency decimals
("honours a currency column scale for fraction digits" in
`useColumnSummary.test.tsx`) is rewritten with its rationale, not
deleted. The objectui#9294 suite's currency fixtures carry `scale: 2`.
They stay green, because EUR and the no-currency width are both 2 on its
fractional amount, and they are untouched.
- **A4.** objectui#10319's changeset states the width as "the resolved
currency's own ISO 4217 minor-unit count, two decimals for USD or CNY,
none for JPY, three for KWD", and this changeset uses the same words.
One difference is deliberate and follows the cell: the edit widget has
no wholeness switch, while the cell drops the fraction of a whole
amount, and footer and tile now do the same.
- **`currencyFractionDigits` is not a public export** of
`@object-ui/fields`. Its `exports` map has only `.`, and the index does
not re-export the helper. Exporting it would move an export set (the
claim's `Clause-②: no` rests on none moving) and touch a file outside
the surface. Both faces reach it through the exported `formatCurrency`
instead.

## Surface additions (declared)

These two pending changesets were made false by this change and are
corrected in place. Their front matter is unchanged.
`check-changeset-overwrite` reports both as its case 2 (correcting
prose):

- `.changeset/grid-summary-footer-tenant-locale-9294.md`: "the currency
arm still reads the column's declared `scale` (objectui#2131)" now reads
"the currency arm kept reading the column's declared `scale`
(objectui#2131; objectui#10221, in its own entry, later moves that arm
to the currency's ISO 4217 minor unit)". "was measured and rejected for
exactly that reason" now reads "was measured and rejected for this card
for exactly that reason".
- `.changeset/9295-percent-surfaces-read-scale.md`: "the corrected
percent arm now sits four lines below a currency arm it finally agrees
with" now says the arm "now reads `scale`, as the currency arm beside it
did when this change was made (objectui#10221 later moves that arm to
the currency's own minor unit)". "An absent `scale` is still zero
fraction digits, matching the currency arm beside it" drops the matching
clause.

## Changeset

`.changeset/10221-currency-decimals-iso-minor-unit.md` bumps
`@object-ui/plugin-grid`, `@object-ui/plugin-dashboard` and
`@object-ui/app-shell` at `minor`. Currency amounts on the footer and
the tile move for metadata relying on `scale`, and for a USD field with
no `scale`. AGENTS.md's version rule files objectui's own behaviour
changes as `minor` in the fixed group, never `major`. The designer
withdrawing a control is a visible change on the same footing.

## Verification (measured on `a5b45f158`; patch round 1 on head
`9539e3d52`, last bullet)

- **Reverse verification.** The fix was committed first (`450ff5176`).
Then the three source files were reverted to base `1dbb9933c`. Disk
proof: the fix's markers `offersScale` and `intlAcceptsCurrency` (the
check patch round 1 replaced) counted 0, and the base reads came back
(`column?.scale ?? 0` counted 2, currency plus percent;
`valueFieldDef.scale ?? 0` counted 1). On that tree the four suites gave
`Tests 43 failed | 29 passed (72)`. The restore used `git checkout HEAD
--` under an EXIT trap, and `git diff HEAD` came back empty (0 bytes).
Two rows predicted GREEN on base came back RED, because their fixtures
carry a stale `scale` the base tree padded to. The docblocks now record
prediction and observation.
- **New and rewritten suites on the fix:** 72 of 72 pass on `a5b45f158`.
- **`plugin-grid` and `plugin-dashboard`, whole:** 272 files in five
chunks, `Tests 2622 passed`, 0 failed.
- **Every other test file that references a touched file:** 31 files,
631 tests pass. These include the 9 `ObjectFieldInspector` suites,
`apps/console` spec-parity suites and
`check-designer-field-key-parity`'s test.
- **type-check.** `tsc --noEmit` plus `tsc -p tsconfig.test.json` exit 0
for `plugin-grid`, `plugin-dashboard` and `app-shell`, each after
building its dependency closure. `--listFilesOnly` confirms the new test
files are in each test program.
- **Lint, narrowed and measured.** `eslint --no-inline-config --format
json` over the 7 touched `.ts`/`.tsx` files: 7 files linted, 0 errors,
44 warnings. The modified files keep their base warning counts
(11/13/6/8). The 6 new warnings are `no-explicit-any` in the two new
test suites, the same idiom as their neighbours, and no `--max-warnings`
is set.
- Population: `eslint.config.js`'s `files: ['**/*.{ts,tsx}']` minus its
`ignores`; all 7 files are inside it.
- Invariance: there is no type-aware linting (no `parserOptions.project`
and no `projectService`), and no rule in `eslint-rules/` reads another
file. So this diff cannot move a verdict on an untouched file.
- **Gates**, all exit 0:
- check-changeset-presence, check-changeset-no-major,
check-changeset-fixed.
  - check:new-line-citations: 0 new.
- check-changeset-overwrite: reports the two corrected changesets above.
- check:changeset-claims: asks for
`.changeset/9269-grid-summary-percent-converged.md` to be re-read. I
re-read it; it makes no currency or `scale` claim.
- check-pending-changeset-literals, check:designer-field-key-parity,
check-vi-mock-override-shape, check-phantom-dependencies,
check-installed-spec-pin-claims.
- check-i18n-dead-keys: report-only. `designer.field.scale` is still
read by number and percent.
- **Patch round 1 (head `9539e3d52`, after merging `origin/main` as
`fc6c55c05`; main had moved 32 commits, none touching this PR's
files).** The first head failed the machine-locale census
(`machineLocaleCensus-9909`): the currency-code check built
`Intl.NumberFormat` with no locale. The check is now `/^[A-Za-z]{3}$/`,
compared against `Intl` on node 22 over 42,542 inputs with 0
disagreements; the census's DECLARED list is untouched. A 6-case
boundary block was added to the footer suite (`usd` and `ZZZ` accepted;
`US1`, `USDX` and `EU` keep the fallback; a malformed tenant tag with
`USD` matches the cell). The census, the four suites and the
objectui#9294 suite gave 6 files, 134 tests passed; the `plugin-grid`
type-check exited 0. CI on `9539e3d52`: 40 success, 3 skipped by design.
- **NOT MEASURED:** the rest of the `app-shell` suite (only the files
referencing the touched files ran), and a browser run. CI runs the full
farm.

## Acceptance notes

- **A stored `scale` on a currency field now has no control that clears
it.** Switching a field's type from number to currency also keeps
`scale`, because `patchDef` spreads the def. This is harmless under the
pinned spec, which still accepts `scale` on currency. Once the refusal
objectstack-ai/objectstack#19909 carries ships in a pinned spec, such a
draft's save is refused with no on-screen control to fix it. The
objectui#4644 precedent strips a retired key on the write path, but a
type-conditional strip is outside this card. Carrier: the pin-bump that
brings objectstack-ai/objectstack#19909 in.
- **A comment made false outside the surface.** The
`PercentCellRenderer` note in `packages/fields/src/index.tsx` says "The
grid footer's currency arm spells the same absence the same way (`??
0`), so the cell and the footer agree." The footer's PERCENT arm still
does, but the currency arm no longer does. The fix is one word
("currency" to "percent"). It is not edited here: the file is outside
the claimed surface and is neither a changeset nor a docs page. Carrier:
the seat.
- The list cell's own malformed-code fallback is locale-less (`CODE
1234.50`), while the footer's is on the tenant locale. That is
pre-existing, reachable only with a code `Intl` refuses, and left alone.
- The footer's column hints still carry `precision`, which no footer arm
reads any more.
- No README or guide sentence was added (AGENTS.md objectstack-ai#2): none states a
currency footer or tile width, and README edits sit outside the claimed
surface.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ui#10221 and the objectui#9910 re-cut) with re-measured pin citations and the lockstep re-record (objectstack-ai#20036)

Fixes objectstack-ai#20029

Clause-②: no

Moves the console pin to objectui `main` `f8a9d0fb0596` (the tip at
2026-09-24T20:41Z). That pin carries objectui#10221 (`0651e7ab4`, a
currency amount's decimals come from its currency, never from `scale`),
which ruling 乙 on objectstack-ai#19910 item 2 requires before PR objectstack-ai#19909 lands. It also
carries the objectui#9910 re-cut (`5ea623ea`, containment is the
declared `children` slot), which objectstack-ai#19969 waits on. The PR folds in the
objectstack-ai#19503 / PR objectstack-ai#19832 precedent's two steps: the sdui-parser lockstep
re-record, and a re-MEASURE of the `packages/spec/src/**` citations that
`check:objectui-pin-citations` names. Every spec edit is to comment or
migration-rationale prose only, plus ONE `.describe()` citation re-point
(the `FormField.span` pin, under PM ruling `5822830122`, option A). No
schema, key, default or export changes. Clause-②: no, because every
authored document is accepted and refused exactly as before.

✅ **All 47 pin citations are now green.** The one `.describe()` this PR
first stopped on (`view.zod.ts:3392`, `FormField.span`) was re-pointed
under PM ruling `5822830122` (option A) on objectstack-ai#20029. See "The describe,
resolved" below.

## Ancestry reading (fix → new pin: AHEAD)

- **REST `compare`: NOT MEASURED.** This session's proxy refuses GitHub
API reads on `objectstack-ai/objectui`. `GET
/repos/objectstack-ai/objectui/compare/0651e7ab4...f8a9d0fb0` answered
"GitHub access to this repository is not enabled for this session".
- **In its place, measured by git** in a FULL clone of
`objectstack-ai/objectui`, where `git rev-parse --is-shallow-repository`
returns `false`. An exit 0 is self-proving, and an exit 1 there is not a
shallow-window artefact.

| command | exit | reading |
|---|---|---|
| `git merge-base --is-ancestor 0651e7ab4 f8a9d0fb0596` | 0 |
objectui#10221 is IN the new pin |
| `git rev-list --count 0651e7ab4..f8a9d0fb0596` | 14 | the new pin is
14 commits AHEAD of the fix (REST would read `ahead`) |
| `git merge-base --is-ancestor 5ea623ea f8a9d0fb0596` | 0 | the
objectui#9910 re-cut is IN the new pin |
| `git merge-base --is-ancestor 5ea623ea 0651e7ab4` | 0 | it is an
ancestor of the fix, too |
| `git merge-base --is-ancestor 62597c588072 0651e7ab4` | 0 | the old
pin is behind the fix (control leg) |
| `git merge-base --is-ancestor 5ea623ea 62597c588072` | 1 | the re-cut
was NOT in the old pin (full clone, so the exit 1 stands) |
| `git rev-list --count 62597c588072..f8a9d0fb0596` | 92 | this hop's
size |

The bump's own changeset lists both: `(objectui 0651e7ab4)` and
`(objectui 5ea623eab)`.

## What moved, and how each artefact was produced

| artefact | producer |
|---|---|
| `.objectui-sha` + `.changeset/console-f8a9d0fb0596.md` | `bash
scripts/bump-objectui.sh --no-commit f8a9d0fb0596…` exit 0. It reads 86
releasing changesets and 9 breaking (all 9 annotated, none declared
major); `@objectstack/console: minor`. The generator's `adr-0087: TODO`
is answered `not-required (no-migration-prescription)`, in the wording
of the last bump that carried breaking entries. |
| the console | `OBJECTUI_ROOT=../objectui bash
scripts/build-console.sh`, through the verify lock, VERDICT command-exit
0 (691 s). The `import/jobs` bundle canary is present. `pnpm
check:console-sha` exit 0: "Console dist matches the objectui pin
(objectui@f8a9d0fb0596)". |
| `sdui.manifest.json` + `scripts/sdui-manifest.record.json` | `node
scripts/gen-sdui-manifest-node.mjs` exit 0. It wrote 59 components,
sha256 `12dd4f8fbf45`. The diff is +39: the `children` slot input
objectui#9910 declared on `alert`, `badge`, `box`, `button`, `card`,
`container`, `flex`, `grid` and `stack`. `node
scripts/check-sdui-manifest.mjs` exit 0. |
| `packages/sdui-parser/objectui-lockstep.json` |
`OBJECTUI_ROOT=../objectui pnpm gen:sdui-lockstep` exit 0. The diff is
3+/3-, only `rev`, `revDate` and `recordedAgainstPin`. Grammar blob
`0131f27cf86d` is unchanged (214 lines), and all 26 diagnostic codes
agree. `pnpm check:sdui-lockstep` exit 0: "byte-identical to
objectui@f8a9d0fb0596". No parser port is owed by the lockstep gate. |
| `packages/spec/src/migrations/registry.ts` | `pnpm --filter
@objectstack/spec gen:migration-registry`, from the six edited entries |

## The objectstack-ai#19969 save/render window (measured, not fixed here)

In the NEW `sdui.manifest.json`, **5 of 59 components** disagree between
`isContainer` (what `packages/sdui-parser/src/validate.ts` still tests)
and "declares a `{ name: 'children', type: 'slot' }` input" (what the
pinned renderer now decides containment by):

| component | `isContainer` | `children` slot | direction |
|---|---|---|---|
| `badge` | false | true | the save gate warns `not-a-container` on
children the renderer renders |
| `alert` | false | true | same |
| `button` | false | true | same. objectui states it on purpose: "⛔ Not
`isContainer`: objectui#6804 ruled that flag means LAYOUT containment"
(`button.tsx:97-104`) |
| `page:tabs` | true | false | the save gate accepts top-level children
that the slot rule does not declare (the children live in
`items[].children`) |
| `page:accordion` | true | false | same |

Non-zero, so objectstack-ai#19969 can be sequenced right behind this PR. The predicate
is ⛔ not ported here.

## Citation re-measure: method

- **Read points.** For each cited objectui file, `git diff --quiet
62597c588072 f8a9d0fb0596 -- FILE`.
- An unchanged file holds its anchors by identity. The prose says so and
keeps the earlier hops' history.
- For a changed file, each cited span was taken at the old pin and
searched for byte-for-byte at the new pin. A moved span is re-pointed. A
span not found was re-READ in the new tree, and its content change is
stated where it is cited.
- **Corpus counts** (six migration entries + `registry.ts`). `git grep
-o -F TOKEN SHA | wc -l`. The method was calibrated first: at
`62597c588` it reproduces every recorded count exactly (8303 files,
`objectstack` 13125, `@objectstack/spec` 5043, `timeout` 1086,
`RuntimeConfig` 240, `resourceLimits` 2, `useState` 2389, `window` 3526,
`period` 170, `interval` 179, `metrics` 324, `TTL` 156, `tenant` 987,
`Span` 486, `SpanSchema` 53).
- At `f8a9d0fb0`, all 100 dark tokens still count 0: every export of the
four cited zod files plus each key name.
- Only the corpus size (8512) and the lit controls move. The two new
`Span` hits are `colSpan` and the word "Spanish".
- `check:future-spec-major`'s measurement exemption to the sentence
"controls objectstack 12966 and @objectstack/spec 4997 on the same
corpus" is kept verbatim. The new figures follow it as "13347 and 5123 …
at this pin", and the gate is exit 0.

## Every `packages/spec/src/**` edit, with its before → after anchor

| site | objectui file(s) | reading at `f8a9d0fb0` |
|---|---|---|
| `api-methods-batch-conformance.test.ts` `sys_api_key` |
`ObjectGrid.tsx` (changed +18/-5: objectui#10083 `rowActionsDeclared`,
objectui#9909 currency locale), `useBulkExecutor.ts` (identical) |
selection block `4024-4051` → `4032-4059`, still hash `c88443302d40`.
`298-303` is identical (hash `01083348330f`) |
| `functional-completeness.ts` calendar / gantt / timeline / map rows |
`ListView.tsx` (changed: objectui#10275 `$select`; objectui#10037 /
objectui#10250 self-query filter and search on gantt / tree / chart),
`ObjectGantt.tsx` (changed: `$search`), `ObjectCalendar.tsx` /
`ObjectTimeline.tsx` / `ObjectMap.tsx` (identical) | no calendar /
gantt-date / timeline / map arm touched; prose restated |
| 6 migration entries + their 6 `registry.ts` copies | whole corpus |
dark tokens 0 at 8512 files; lit controls re-counted (see method) |
| `component.zod.ts` / `component.test.ts` tabs + accordion `icon` |
`containers.tsx` (changed, `ba0b61a60`: an import line + `page:header`)
| `853-859`, `912`, `1069-1075`, `1116` did NOT move, text
byte-identical |
| `component.zod.ts` / `component.test.ts` button `icon` | `button.tsx`
(changed, `5ea623eab`), `resolve-icon.ts` / `lazy-icon.tsx` (identical)
| ⚠️ registration inputs CONTENT grew: `85-97` → `85-105` (+ `children`
slot input), `defaultProps` `98-102` → `106-110`; still no `icon` input.
`:43` / `:72` / `:74` unmoved |
| `component.zod.ts` metric `icon` | `ObjectMetricWidget.tsx` (changed,
`0651e7ab4`), `index.tsx` / `MetricWidget.tsx` (identical) | destructure
`174` → `181`, forward `483` → `528`, text byte-identical |
| `component.zod.ts` / `component.test.ts` kanban `limit` ×3, `quickAdd`
×2, `navigation` | `ObjectKanban.tsx` (changed, objectui#10068
`$orderby`), plugin-kanban `index.tsx` (changed), `objectql.ts`,
`ElementDataSourceGate.tsx`, `ListView.tsx`, plugin-view
`ObjectView.tsx` (all changed); `types.ts`, `element-data-source.ts`,
`KanbanImpl.tsx`, `plugin-kanban.mdx` (identical) | ⚠️ query CONTENT
gained `$orderby` (`674-679` → `680-690`), mapping gained `sort: true`
(`447-450` → `507-511`); moved byte-identical: `:84`→`:85`,
`:553`→`:554`, `:559`→`:565`, `:676`→`:687`, `:1210`→`:1225`,
`:1218-1219`→`:1233-1234`, `:1468`→`:1483`, `:1484-1497`→`:1499-1512`,
`:1563`→`:1578`, `:1587`→`:1602`, `index.tsx:313`→`:363-364`,
`objectql.ts:3735`→`:3832`, Gate `316-331`→`373-388`,
`192-194`→`200-202`, ListView `2979`→`3067`, `2952`→`3040`, ObjectView
`1638`→`1666`, `1579`→`1607`. `onQuickAdd` / `quickAdd` still 0,
`onCardClick` still 11 |
| `component.zod.ts` calendar `navigation` | `ObjectCalendar.tsx`
(identical) | anchors hold by identity |
| `component.zod.ts` `object-map` header, `filter`, `sort` |
`ObjectMap.tsx`, plugin-map `index.tsx`, `record-source.ts` (identical)
| anchors hold by identity |
| `component.zod.ts` `object-gantt` header | `ObjectGantt.tsx` (changed
+29/-2, objectui#10250) | `:501-503`, `:613` unmoved; the rest moved 21
/ 29 lines byte-identical (e.g. `844`→`865`, `1615`→`1644`,
`2181`→`2210`, `2144`→`2173`). ⚠️ new undeclared reads `search` /
`searchableFields` (`:776-779`, `:880-885`) are recorded, host-generated
by `ListView`'s toolbar |
| `component.zod.ts` flat `TreeConfig` guidance + `object-tree` header +
registry-shell count | `ObjectTree.tsx` (changed +69/-3: objectui#9549,
objectui#9136), `ListView.tsx` (changed); `index.tsx`,
`record-source.ts` (identical) | ⚠️ two CONTENT changes, restated: the
`filter.tree` stash read is DELETED (`getTreeConfig` `235-248` →
`246-259`, `tree` `:236` → `:247`), and `filter` gains a second read on
the inline `value` provider (`:837`); `ObjectTreeSchema` now declares
`filter` (objectui#9549). Moved byte-identical: `582`→`593`,
`871/922/985`→`937/988/1051`, `813-814`→`879-880`, `742`→`753`,
`908`→`974`, `891`→`957`, `775`→`786`, `741-753`→`752-764`, `751`→`762`,
`750`→`761`, `197-218`→`198-219`, ListView `3261-3275`→`3366-3385`,
`3270`→`3380`. `ElementDataSourceGate` count re-taken: tree 0, map /
gantt / grid / calendar 3 each |
| `component.zod.ts` `object-timeline` header + `data` read |
`renderer.tsx` (changed, `0b6b295a1`), `ObjectTimeline.tsx` /
`index.tsx` (identical) | `1215`→`1240`, `1505`→`1531`,
`1442-1457`→`1468-1483`, text byte-identical |
| `component.zod.ts` `ComponentPropsMap` comments (map/gantt/tree;
timeline) | as above | restated to match the headers |
| `dataset.zod.ts` measure `format` | `dataset-format.ts`,
`date-display.ts` (both changed, objectui#10026) | `formatDate`
`271-306` → `355-390` and `225` → `309` byte-identical;
`formatMeasureDate` `229-264` → `229-263` (comment-only change), call
`370` → `369`, datetime arm `260-262` → `259-261`; the style handling is
unchanged |
| `view.zod.ts` view-face `limit` reach (objectstack-ai#19228) | 9 files | instrument
1: probe 0; control 14 lines / 7 files (was 13 / 6; the new one is
`RelatedList.selectFls-10186.test.tsx:314`, executable ⇒ 9 executable,
was 8). Instrument 2: the same four flattening spreads and the same
exclusions. Every anchor re-pointed byte-identical (e.g.
`ListView.tsx:3084`→`3172`, `4702`→`4815`, `ObjectView.tsx:1725`→`1753`,
`2319`→`2347`). Both verdicts stand |
| `view.zod.ts` `ListMapConfigSchema` content-asserted anchors |
plugin-view `ObjectView.tsx`, `objectql.zod.ts` (changed) | `case
'map':` `1764` → `1792` (17-line block identical),
`ObjectMapConfigSchema` `1574` → `1589` (47-line declaration identical),
`LIST_VIEW_LOCAL_OVERRIDES` `734` → `741` (list identical, still no
`map`). `--verify-anchors` passes all content assertions |
| `view.zod.ts` `FormField.span` DOCBLOCK | `form.tsx` (changed,
`5ea623eab`: registration inputs only), `autoLayout.ts` (identical) |
`spanLadderFor` `:204-231` is byte-identical, so it still emits the
ladder |

## The describe, resolved (PM ruling `5822830122`, option A)

The ruling widened the file surface by exactly three items, all landed
in one new commit (`90f9a30`):

| file | what |
|---|---|
| `packages/spec/src/ui/view.zod.ts` | the `FormField.span`
`.describe()` citation, `.objectui-sha` = `62597c588` → `.objectui-sha`
= `f8a9d0fb0596`; nothing else in the sentence changes. The claim was
re-read true at the new pin: `plugin-form`'s `autoLayout.ts` is
byte-identical, and `form.tsx`'s `spanLadderFor` `:204-231` is
byte-identical |
| `content/docs/references/ui/view.mdx` | regenerated by `pnpm --filter
@objectstack/spec gen:schema && pnpm --filter @objectstack/spec
gen:docs` (VERDICT command-exit 0): the two `span` rows only (+2/-2).
**No other generated file moved** |
| `.changeset/20029-pin-bump-describe-correction.md` |
`'@objectstack/spec': patch`, stating the describe correction, in the
shape of `17429-` / `19503-pin-bump-describe-corrections.md` |

## Gates

`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` on head `90f9a30` derives 124 families (the `view.mdx` edit
adds the docs families). `--ran` reads: "124 derived famil(ies)
accounted for — 121 run, 3 NOT-MEASURED".
- **All run families exit 0**, among them: `pnpm --filter
@objectstack/spec run check:objectui-pin-citations` ("47 … match
.objectui-sha"), `pnpm check:sdui-lockstep`, `pnpm check:console-sha`,
`node scripts/check-sdui-manifest.mjs`, `pnpm --filter @objectstack/spec
run check:generated`, `check:docs`, `check:objectui-changeset`,
`check:future-spec-major` and `check:migration-registry`.
- Five docs families first read exit 3 (unbuilt `@objectstack/lint` /
`formula` / `client-react`). After `turbo run build` of those closures
they read exit 0: `check:doc-formula-expressions`,
`check:doc-security-posture`, `check:skill-examples`,
`check:docs-transcript-drift`, `check:lean-entry-closure`.
- **NOT MEASURED, exit 3 (PREREQUISITE NOT MET), left to CI:**
`check-plugin-teardown-shape --self-test` (shallow clone);
`check:dual-build-cjs-loads` and `check:type-check-debt` (both need a
full workspace build). This diff changes no code in any package those
read.
- **Tests:** `@objectstack/spec` vitest on `component.test.ts`,
`api-methods-batch-conformance.test.ts`, `src/migrations`,
`functional-completeness`, `src/ui/view*` and `src/ui/dataset*`: 26
files, 1666 / 1666 pass (head `530fc4d`); re-run on `src/ui/view*` at
head `90f9a30`: 19 files, 1085 / 1085 pass.

Changed lines: **884** (+600 / -284, 20 files), under the 5000
threshold.

## Acceptance notes

- **Stale metric test anchors, out of the gate's population.**
`component.test.ts`'s metric `icon` test body still cites
`index.tsx:204`, `ObjectMetricWidget.tsx:142` / `:474` and
`MetricWidget.tsx:312-321`. It carries no pin citation, so it is outside
the gate's population. Those anchors were already stale before this hop:
the docblock beside them reads `:237`, `:181` / `:528` and `:351-360`.
Left as they are, because the gate does not name them.
- **`object-kanban` reads `sort` now** (objectui#10068, via
`ElementDataSourceGate` from the binding's `dataSource.sort`). The node
schema declares no `sort`. The key reaches the board from the binding,
not from the authored node, so this is not an authoring gap. Recorded,
not acted on.
- **The branch name is `claude/issue-20029-objectui-pin-bump`**, as the
claim names. The console changeset is the script's own
`console-f8a9d0fb0596.md` (the precedent's spelling), not a hand-named
`20029-*.md`.

## 维护者速读(草稿)

- **改了什么**:把控制台钉住的 objectui 版本从 `62597c588` 推进到 `f8a9d0fb0`(objectui
main 当时的最新提交),其中包含货币小数位修复 objectui#10221(`0651e7ab4`)和容器判定改用 `children`
插槽的 objectui#9910(`5ea623ea`)。同时按先例重新生成 SDUI 清单、重录 sdui-parser
对齐记录,并在新版本上逐条重新核对 spec 里引用 objectui 代码行号的注释(47 处中 46 处已改)。
- **为什么改**:裁决乙要求 objectui#10221 先进入发布的控制台,PR objectstack-ai#19909 才能落地;同一次推进也是 objectstack-ai#19969
在等的前提。
- **风险与代价(含回滚)**:spec 只改注释和迁移说明文字,外加按 PM 裁决 `5822830122`(选项 A)把
`FormField.span` describe 里引用的钉住版本改为新版本(附 spec patch changeset,重新生成
`view.mdx`);不改任何 schema、键、默认值或导出,已写的元数据接受/拒绝结果完全不变。控制台行为随 objectui 这 92
个提交变化(其中 9 条标注 breaking,都是 objectui 自身的类型面)。清单里有 5 个组件的 `isContainer` 与
`children` 插槽不一致(badge、alert、button、page:tabs、page:accordion),这是 objectstack-ai#19969
要补的保存/渲染窗口。回滚:还原本 PR 即可。
- **席位意见**:
- **你要做的**:describe 已按裁决 A 处理,门禁全部跑绿(3 项需完整构建的留给 CI)。落地前还需要一次同档合约审查。

Dev session: `session_01BMUTTbeXStLRWxU9zEWDJq` (PM seat
`domain:devx#1`, `session_01VDtqoecgES7ScQYGbFVDRv`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BMUTTbeXStLRWxU9zEWDJq

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… paths (objectstack-ai#20056)

Fixes objectstack-ai#20034
Clause-②: no

## Patch round 1 (head `10b1176328`)

Added on top of the reviewed head `4c216561c6` (at-tier review PASS,
comment 5824559177). It carries the implementer's own two out-of-scope
findings and the reviewer's Clause ② reading:

- **ADR-0087 D3 entry `automation-runs-cursor-retired`**:
`packages/spec/src/migrations/entries/semantic/18.automation-runs-cursor-retired.ts`
`:11`, `:43` and `:72` now name `GET /api/v1/automation/:name/runs`, the
path this PR's contract publishes and the dispatcher mounts.
`packages/spec/src/migrations/registry.ts` was regenerated with `pnpm
--filter @objectstack/spec gen:migration-registry` (not hand-edited; the
diff is the same three lines at `:5943`, `:5975` and `:6004`).
`gen:upgrade-guide` and `gen:spec-changes` were re-run and changed no
bytes, because the entry is in step 18, beyond `PROTOCOL_MAJOR` 17. The
text still ships today as data in `MIGRATIONS_BY_MAJOR[18]`, which is
why it is corrected now. Open PR objectstack-ai#20031 regenerates a different region
of `registry.ts`; whichever of the two lands second regenerates.
- **Two comments**: `packages/runtime/src/query-param.ts:179` and
`packages/services/service-automation/src/run-list-truncation.test.ts:6`
now quote the `/api/v1` path. Both are comments only.
- **Clause ②**: `.changeset/20034-automation-contract-api-v1-paths.md:9`
and this body's line 2 now read `Clause-②: no`, with no arm. This diff
adds no key, widens no accepted input and adds no export
(`scripts/pm/clause2-line.mjs:70`). The level stays `minor`.
- No pending changeset quotes a sentence of the D3 entry.
`.changeset/19365-automation-runs-cursor-hasmore.md:117` carries only
the registration marker naming the entry's id, and the id is unchanged.
So this round needs no further deliberate correction.

## What this changes

`AutomationApiContracts` (`@objectstack/spec/api`) declared its nine
flow endpoints under `/api/automation`. The dispatcher mounts the
automation door at `config.prefix || '/api/v1'` plus `/automation`, and
`objectstack serve` passes no prefix, so every declared path answered
`404 ENDPOINT_NOT_FOUND` on the default composition (measured on a
composed runtime by the objectstack-ai#19966 dev). This PR takes remedy 1: the
contract moves to the served paths. **The runtime and dispatcher are
unchanged.**

- `packages/spec/src/api/automation-api.zod.ts`: the nine `path` values,
the module's `Base path` line and endpoint list, and every other in-file
path quote (section headers, `@example`s, the resume docblock, and the
`cursor` tombstone text `ListRunsRequestSchema` raises) move from
`/api/automation…` to `/api/v1/automation…`. After the edit the file
holds 0 occurrences of `/api/automation` (28 moved, plus the 10 docblock
lines rewritten).
- `packages/spec/src/api/automation-api.zod.test.ts`: the nine path pins
move with the values.
- `content/docs/references/api/automation-api.mdx`: regenerated with
`pnpm --filter @objectstack/spec gen:docs` (not hand-edited).
- `packages/runtime/src/automation-api-contract-mounts.test.ts` (new):
the drift pin, below.
- `.changeset/20034-automation-contract-api-v1-paths.md` (new):
`@objectstack/spec` `minor`.
- `.changeset/19365-automation-runs-cursor-hasmore.md`: a deliberate
correction of a pending note, below.
- Patch round 1:
`migrations/entries/semantic/18.automation-runs-cursor-retired.ts` and
the regenerated `migrations/registry.ts`, plus comments in
`packages/runtime/src/query-param.ts` and
`packages/services/service-automation/src/run-list-truncation.test.ts`.

## Reproduction, at base `adbbc5d01e`

- Spec: `automation-api.zod.ts:14` `Base path: /api/automation`;
`:658`–`:706` nine `path` values under `/api/automation`; the test
pinned all nine to themselves (`automation-api.zod.test.ts:803`–`:811`).
- Runtime: `dispatcher-plugin.ts:909` `const prefix = config.prefix ||
'/api/v1';`; `registerAutomationRoutes(base)` mounts
`${base}/automation…` (`:1465` onwards), called with `prefix` at
`:1746`, and with `${prefix}/environments/:environmentId` at `:1742` /
`:1750` when project scoping is on.
- Route ledger: `route-ledger.ts:429` `POST /automation` (client
`automation.create`) and siblings; the header (`:17`) says to prepend
`/api/v1` for the wire path.
- CLI: `packages/cli/src/commands/serve.ts:4412` calls
`createDispatcherPlugin({ scoping, enforceProjectMembership,
observability, rateLimit })`, no `prefix`; scoping defaults to off
(`:4340`).

## Consumer search: nothing depends on the unversioned form

| candidate | reads the contract's `path`? | verdict |
| --- | --- | --- |
| `packages/adapters/hono/src/hono.test.ts:453` (`GET /api/automation
delegates to dispatch()`) | no | Not a consumer. It drives
`createHonoApp` with the adapter's own default `prefix` (`options.prefix
\|\| '/api'`, `hono/src/index.ts:303`) against a mocked dispatcher and
asserts the dispatcher-internal `/automation`. It never imports the
contract. |
| `packages/client` | no | Builds automation URLs from discovery or its
`/api/v1/automation` convention (`getRoute('automation')`); it never
names `AutomationApiContracts`. Two comments name the spec test file
`automation-api.zod.test.ts`, not the constant. |
| everything else in this repo | no | `AutomationApiContracts` occurs
only in its declaring file, its spec test, `api-surface/api.json` (name
only) and `export-origins/api.json`. No generator reads the path values.
|
| objectui at the pinned `.objectui-sha` `62597c588` | no | `git grep
-F` at that commit: `AutomationApiContracts` 0 files, `/api/automation`
0 files; positive control `/api/v1/automation` 33 files. |
| `objectstack-ai/cloud` and npm consumers | not measured | not checked
out here |

One served surface does use the unversioned form: a host built with
`createHonoApp({ kernel })` and no `prefix` serves the whole dispatcher,
automation included, under `/api`. That is a documented adapter default,
and it applies to every contract family: under that host every other
`*ApiContracts` row (`/api/v1/…`) is off by the same segment. The old
automation paths matched it by coincidence, not by design, and no code
reads the contract under that host, so remedy 2 does not apply. The
changeset says how such a host maps the contract paths.

## Deliberate correction of a pending release note

`.changeset/19365-automation-runs-cursor-hasmore.md` (pending, not yet
released) quotes the `cursor` tombstone text in its FROM/TO block. That
text is one of the path quotes this PR moves, so the note became false.
Its line 32 changes from

-> throws: '`cursor` was removed from GET /api/automation/:name/runs in

to

-> throws: '`cursor` was removed from GET /api/v1/automation/:name/runs
in

Nothing else in that note changes. This is the DELIBERATE CORRECTION
class that `check-empty-changeset.mjs` names. `skip-changeset` is not
applied, and `Check Changeset` stays red **by design**. The same-head
at-tier review (comment 5824559177) names the note and judges the
changed sentence. No other pending changeset quotes an unversioned
automation path. At the base, `git grep -n "/api/automation" --
'.changeset/*.md'` showed only that line. Patch round 1 corrected no
further note: no pending changeset quotes the D3 entry's sentences.

## Changeset level

`minor`, not declared breaking. The `path` type stays `string`, no
accepted input narrows, no method changes, and the old values named
paths that no route served on the default composition, so a caller that
read the constant gets a working URL now without changing code. This
follows the precedent of the `PackageApiContracts.installPackage.path`
rebind (`.changeset/18058-install-door-contract-rebind.md`, `minor`, not
breaking). The declaration is `Clause-②: no` with no arm, in both this
body and the changeset. It answers the reader's question "does this
widen an accepted input or grow the public surface?" and the answer here
is no: no key added, no accepted input widened, no export grown. It is
not `(narrowing)` either, because nothing an author writes is removed.
`minor` is valid under `no`: a published constant's value moves, and
`patch` is a floor, not a ceiling. The first head declared `yes`, copied
from the claim, and the reviewer judged that over-declared.

## The drift pin, and proof that it can fail

`packages/runtime/src/automation-api-contract-mounts.test.ts` has two
legs:
1. **mount**: it starts `createDispatcherPlugin` with **no** `prefix`
(the composition `objectstack serve` builds) on a server that records
registrations, and requires every contract `METHOD path` to be one of
them. The prefix comes from the plugin's own default, not from a
constant in the test.
2. **ledger**: every contract route must be a `route-ledger.ts` row
under the documented `/api/v1` wire prefix. The live-mount parity gate
probes those rows through the real router.

The runtime vitest config aliases `@objectstack/spec/*` to spec
**source**, so the spec side of the pin reads `src/`, not a build. The
ablation was run on the committed tree (`4c216561c6`) with
`scripts/ablation-replace.mjs`, one leg at a time, with restores
anchored on `HEAD`:

| leg | mutation (landed on disk: anchor 1 → 0, blob moved) | pin result
|
| --- | --- | --- |
| contract | `getRun.path` back to `/api/automation/:name/runs/:runId` |
mount red, ledger red (`getRun` named), 1 passed |
| dispatcher | default prefix `'/api/v1'` → `'/api/v2'` | mount red (all
nine named), 2 passed |
| restored | none (blobs equal `HEAD`, `git diff HEAD` empty for both
paths) | 3 passed |

## Environment-scoped mount

The contract does not carry
`/api/v1/environments/:environmentId/automation…`, and this PR does not
add it. No `*ApiContracts` map declares the scoped variants. Scoping is
one mount-time transformation the dispatcher applies to automation,
actions, AI and packages alike, and the client derives scoped URLs from
discovery. If the variants are ever declared, that belongs once in a
contract shared by all the families, not copied into each map. Note that
under `projectResolution: 'required'` none of the nine unscoped paths is
mounted (`dispatcher-plugin.required-scoping-mounts.integration.test.ts`
pins that).

## Verification (head `10b1176328`, patch round 1)

- Tests, each package's full local project, under the verify lock at
this head. The lock's verdict is `batch-last-exit 0`: the last part of
the batch requires all three suites to exit 0, and the batch printed
`SUITES spec=0 service-automation=0 runtime=0`.
  - `@objectstack/spec`: 532 files, 15648 passed, 2 todo.
  - `@objectstack/service-automation`: 144 files, 1725 passed.
- `@objectstack/runtime` (`--project local`): 277 files, 3896 passed, 1
skipped, including the drift pin's 3 cases.
- Build: `turbo run build --filter='./packages/*'
--filter='./packages/*/*'` at this head: 72 of 72 tasks succeeded,
including the tsup and declaration builds of spec, runtime and
service-automation.
- Spec generated artifacts: `check:generated` reports "All 15 generated
artifacts are up to date". `check:migration-registry` reports
"src/migrations/registry.ts is current (242 semantic, 210 retired-key,
183 retired-def)". `check:spec-changes` and `check:upgrade-guide` both
report up to date. `check:api-surface` reports "public API surface +
factory signatures unchanged", and `check:docs` reports "225 generated
files in sync".
- Derived gate set, taken after `git fetch origin main`
(`dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`,
merge base `adbbc5d01`, 10 paths): 113 families, 6 more than round 0
(`check:migration-registry`, `check:spec-changes`,
`check:upgrade-guide`, `check:future-spec-major`, and
`check-tenant-audit-census` with its self-test). All 113 ran, and
`--ran` reports "113 run, 0 NOT-MEASURED" with 0 UNRUN. 112 exited 0.
`node scripts/check-empty-changeset.mjs --base origin/main` exits 1 on
the deliberate correction, as designed. No family answered PREREQUISITE
NOT MET this round.
- The branch is behind `origin/main` (15 commits at the seat's re-read).
Three of those commits regenerated one of this PR's 10 paths,
`packages/spec/src/migrations/registry.ts`: objectstack-ai#20036 (`0bf85eaae6`),
objectstack-ai#19909 (`5b9402d89b`) and objectstack-ai#19818 (`66960564d9`). `git merge-tree
--write-tree` of `origin/main` and this head is clean, and the at-tier
re-review measured that objectstack-ai#20036's and objectstack-ai#19909's hunks do not touch this
PR's region (`:5940`–`:6004`). The merge queue's rebuilt generation
regenerates the file. The derivation's one changed family input across
that range is `scripts/sdui-manifest.record.json`, from objectstack-ai#20036.
(Corrected by the seat after the at-tier review `5825376693` found the
earlier sentence, "None of this PR's 10 paths is touched by those
commits", false.)
- Clause ② and ADR-0087, run offline with this body as the
`pull_request` event:
- `check-changeset-no-major --event` prints "✓ This diff introduces no
`major` bump." and "✓ LEVEL AXIS: this PR declares clause-② `no`, so no
package here is declared to have grown a published surface."
(declaration line `Clause-②: no`, no arm).
- `check-adr-0087-registration` prints "✓ … this PR adds no
declared-breaking changeset (2 non-breaking changeset(s) seen)". This
gate reads the Clause ② arm from the changeset body
(`readClause2Line(parsed.body)`), not from the PR event. Its verdict is
the same with and without `--event`.
- Lint, narrowed and proven: all 7 touched TS files are in the eslint
population (`--print-config` resolves each). `--no-inline-config
--format json` reports 7 files, 0 errors, 0 warnings.
`eslint.config.mjs` enables no type-aware linting: all seven
`parserOptions` blocks are `{ ecmaVersion, sourceType }` only, with no
`project` or `projectService`. So this diff cannot change the verdict on
any untouched file. The repo-wide `pnpm lint` is left to CI.
- Round-0 evidence still stands, and its sources are unchanged in round
1: the ablation above on `4c216561c6`, and the spec and runtime
`typecheck` runs, both exit 0. Round 1 changes only string literals (the
D3 entry and its registry mirror), comments and one changeset line. The
type-check-debt gate re-measured at this head: "4 ledger entr(ies) …
none above its recorded number".

## Acceptance notes (observations, not filed)

- `RouterConfigSchema` (`spec/src/api/router.zod.ts`) defaults
`basePath` to `/api` with `mounts.automation: '/automation'`. That
spec-only declaration has no runtime reader in this repo.
- The contract lists nine of the 17 routes `registerAutomationRoutes`
mounts. The ones not listed are resume, cancel, restore-suspension,
screen, actions, connectors, `_status` and the legacy trigger form.
- **Every remaining `/api/automation` path at head `10b1176328`, and why
it stays** (`git grep -n "/api/automation\b"`, with the `automation-api`
file-name hits filtered out):
- `.changeset/20034-automation-contract-api-v1-paths.md` `:5` and
`:15`-`:20`: the FROM column of this PR's own FROM/TO table.
- `packages/adapters/hono/src/hono.test.ts:453`-`:454`: the Hono
adapter's own default `prefix` (`/api`), driven against a mocked
dispatcher. It does not read the contract.
- `packages/runtime/src/automation-api-contract-mounts.test.ts:9`: the
pin's docblock, describing the drift it guards.
- `packages/spec/scripts/file-description.test.ts:878`-`:937`: synthetic
fixtures for the docblock-description extractor. They do not quote the
contract.
- `packages/spec/src/api/router.test.ts:343`: a custom-mounts fixture
for `RouterConfigSchema`.
- Released `CHANGELOG.md` entries: `packages/client/CHANGELOG.md:3230`,
`packages/runtime/CHANGELOG.md:11589`,
`packages/services/service-automation/CHANGELOG.md:5802` and
`packages/spec/CHANGELOG.md:32723` quote `GET
/api/automation/:name/runs` in the released ExecutionStatus-filter
entry. These are release-owned and never edited in a code PR; an
amendment would be a dedicated docs-only PR.
`packages/spec/CHANGELOG.md:14982` names the file `automation-api.mdx`,
not a path.

Implemented in session `session_019c3Hi6ZMU1p6m6aA6Bz45d` (claim
5823821835; patch round 1 dispatched by the `domain:spec` seat 4).

---------

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/l tests tooling

Projects

None yet

2 participants