fix(spec,objectql)!: retire scale from the currency field type — refused at parse, no longer enforced on writes - #19909
Conversation
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>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…rrency-scale-retired
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>
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>
|
Landing-order note (ruling 乙 item 2): the first half is met. 2026-09-24T20:10Z objectstack-ai/objectui#10221 landed as objectui The second half is still open: this repository's 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
Generated by Claude Code |
Contract reviewServed-tier: ① Derived judgmentsRound-3 delta is exactly the required fix, and nothing else moved. Blocking item fixed — right. Sweep of every other shipped form surface — clean. i18n bundles — need not move, and did not. The extractor keys New pin test — sound, no new defect. The added block in Changeset delta — true. The one added sentence ("Studio's object editor no longer offers ② Semver levelUnchanged and still correct: ③ Boundary flags
Implemented-by: 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 |
…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>
…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>
…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>
… 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>
Fixes #19629
Clause-②: no (narrowing)
Executes maintainer ruling
5791803339(batch #215 item 1, letter B, 「215 同意」):scaleis retired from thecurrencyfield type. Its refusal remedy and its ADR-0087 migration entry are worded by maintainer ruling 乙5805782503(batch #218 item 2, 「其他同意」), item 1: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-shapin 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 is62597c588072and 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
@objectstack/spec,FieldSchema(packages/spec/src/data/field.zod.ts):scaleon atype: 'currency'field is refused at parse, with onecustomissue atscale(the file's per-typesuperRefinehouse pattern). No alias and no grace window. The remedy's first sentence is "scaleis not valid on acurrencyfield — 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. Thescaledescribe names the set the key still applies to:number,percent,ratingandslider, enforced on writes, andformula, rounded. Oncurrencyit now reads "REFUSED on acurrencyfield — delete it there".@objectstack/objectql, record validator (packages/objectql/src/validation/record-validator.ts): themax_scalebranch no longer readsdef.scaleforcurrency, so the type leaves the enforced set.min,maxand the finite-number check still apply to currency.percent,number,ratingandsliderare unchanged. Nothing else is read in its place: a currency's write allowance stays unconstrained.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.tsis regenerated withgen:migration-registry, never hand-edited.scale: 2was 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)scale: 2was deleted from 13 currency examples incontent/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) andprotocol/objectql/types.mdx. Theschema.mdxproperty table'sscalerow no longer listscurrency. It now says "Refused oncurrency— delete it there".content/docs/references/data/field.mdx,content/docs/references/data/object.mdxandcontent/docs/references/system/migration.mdx(thescaledescribe).authorable-surface,api-surface,json-schema.manifest,spec-changes.jsonand the upgrade guide did not move, because no key or export was added or removed. The nine*.metadata-forms.generated.tsi18n bundles did not move either: round 3 changes avisibleWhen, not a label or help text, and those bundles carry only label and help text.packages/spec/src/data/field-scale.ts: the docblock'scurrencybullet records the retirement and ruling 乙's remedy. It no longer namesCurrencyConfigSchema.precisionas the money faces' override; that claim was false at the pin.packages/spec/src/data/object.form.ts(round 3): the object designer's quick-add fields grid,objectForm, is the formMETADATA_FORM_REGISTRYserves for theobjectmetadata type. Itsscalerow'svisibleWhenwas"data.type in ['number','currency','percent']"and is now"data.type in ['number','percent']", with a one-line comment citing this card and ruling5791803339B. Theprecision,minandmaxrows are unchanged.Measured first, on
origin/main1f89ba0d70(builtdist)scale: 2parsed:FieldSchema.safeParsesucceeded. Control: the same withtype: 'number'also succeeded.scale:scale: 2, write1.234→max_scale {"scale":2,"actual":3}. A currency field withoutscaleaccepted1.23456.max_scalereaddef.scaleon all four controls. Each was givenscale: 2and the write1.23456.number,ratingandslideranswered{scale:2, actual:5}.percentanswered{scale:4, actual:5}, because of the fraction-storage derivation..ts/.tsx/.mjs/.jsfile (7,036 files): 15 example sites, all listed above, and 7 test fixtures, listed below. A JSON walk over 556 tracked.jsonfiles found 0 currency-typed objects withscale. A windowed scan of 831.md/.mdxfiles found the 13 documentation examples. The one surviving AST hit ispackages/spec/src/data/inline-related-columns.test.ts:103. It isInlineGridColumnSchema.scale, a different schema that the ruling does not cover.Readings the round measured, and where each went
currencyConfig.precisionhas no reader. No face in the pinned console (.objectui-sha62597c588072) 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-levelprecision. → Ruling 乙 item 1: the remedy and the migration entry no longer point to that key. Two findings follow from ruling 乙 item 3:currencyConfig.precisionis declared and read by nothing, and objectui'sCurrencyFieldreads the field-levelprecisionas decimal places. Both are filed by thedomain:specseat, 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.Field.currencydeclarations inexamples/declaredscale: 2. → Corrected on the card by5805294161.ObjectMetricWidget's currency arm readsvalueFieldDef.scale ?? 0. → Ruling 乙 item 2: objectui#10221 is widened to it and lands first (see the landing order above).Clause-②: no (narrowing)stays as ruled. The widening reading from the finding(spec,objectql): ruling B's percent storage derivation is NOT live —max_scalehas no percent arm and thepercentScaleOfdocblock is silent aboutscale#19320 precedent is noted and ⛔ not re-declared.Round 3, on top of
40ef043003Why. The at-tier contract-review record
5818558748on this PR (head40ef043003) read VERDICT: FAIL with one required fix:packages/spec/src/data/object.form.ts:190still offeredscaleon a currency row of the object designer's fields grid, the form registered inpackages/spec/src/system/metadata-form-registry.ts. After this PR an author using that form is shown ascaleinput 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. Thescalerow'svisibleWhenis now"data.type in ['number','percent']", with the one-line comment. The row was not widened to any other type, and theprecision,minandmaxrows were not touched. The same commit adds the offer-side pin and one changeset sentence (both below).origin/mainwas not merged: the PR readsmergeable_state: clean, and no file main changed since the merge basec8399867b8is a file this branch changes (commof 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 offeringscaleoncurrency. All 17*.form.tsfiles were read forscalerows, and every non-test source file naming bothscaleandcurrencywas read at the hit.d7740c324e)data/object.form.ts:191objectFormfields-gridscalerow (it was:190)data/field.form.ts:79fieldFormscalerow,"data.type == 'number'"data/field.zod.ts:1235-1236FieldSchema.scaleand its describe, "REFUSED on acurrencyfield"data/field.zod.ts:1025FieldSchemaaliasesdecimals/decimalPlaces→scaledata/field.zod.ts:420CurrencyConfigSchemaaliasscale→precision, insidecurrencyConfigdata/field.zod.ts:931InlineGridColumnSchema.scaleui/view.zod.ts:3338FormFieldSchema.scale, a form-view row's widget override with no type gate by design (a form row usually omitstype)ui/view.zod.ts:1600scale(hour…year)ui/bulk-action.zod.ts:157scaleinBULK_PARAM_WIDGET_CONFIG_KEYS, a list of keys bulk params REFUSEdata/field-scale.ts:101ABSENT_SCALE_BY_TYPE, whose one row ispercentdata/numeric-column-representation.ts:49-52currencyfield withscale: 2refused at the write seamstudio/**currencyhitspackages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts(outsidepackages/spec/src)fields.scalecarries only its label and help text (en:147-150)check:i18nreads all nine packages' bundles in syncA quoted-exact
git grep -n -F "['number','currency','percent']"over the whole tree now answers one line, theprecisionrow atobject.form.ts:189, which the record says to leave. Before the fix it answered that row and thescalerow.The new pin (
packages/spec/src/data/field-currency-scale-refused.test.ts, final block). It evaluates each row'svisibleWhenthe wayform-delete-behavior-options.test.tsalready does: a fail-closed reader of thedata.typespellings these forms use (==andin [...], joined by||) that throws on anything else, so a predicate it cannot read fails the test instead of being assumed visible or hidden.METADATA_FORM_REGISTRYfinds exactly twoscalerows,field:scaleandobject:fields.scale.METADATA_FORM_REGISTRY.objectisobjectForm. The reader lights on a row that does namecurrency.objectFormscalerow is not offered whendata.typeiscurrency.numberandpercent, and the door agrees on both types (FieldSchema.safeParsewithscale: 2succeeds).scaleon a currency field.Old-row pins. Nothing pinned the old row: before the fix, the quoted-exact
git grepabove found the predicate only inobject.form.tsitself, and none of the tests that readobjectFormorMETADATA_FORM_REGISTRYmentionscale. There was nothing to reverse.Round 2, on top of
7c0a33c6ad07c9be36bf: a merge oforigin/mainc8399867b8, run throughscripts/pm/os-regen-merge.sh.registry.tstext-merged, andgen:migration-registrythen wrote no change.check:generatedread "All 15 generated artifacts are up to date".api/package-api.mdx,security/permission.mdx,system/translation.mdx) are byte-identical toorigin/main(git diffempty).git grepsurvival checks:origin/main's and HEAD'sregistry.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-onlyandview-filter-rule-absent-value-refused.permission.mdxon both sides.e2c5077421: the rewording to ruling 乙, across the refusal message, the describe, the migration entry (withregistry.tsregenerated), the validator's comments, thefield-scale.tsdocblock, theschema.mdxrow, the changeset and the spec pins. What is refused and what is accepted did not change.40ef043003:gen:docsregenerated the three reference pages. A latergen:schema && gen:docsproduced no further diff, andcheck:docsread "225 generated files in sync with packages/spec".Tests, on HEAD
d7740c324eEvery command went through
scripts/pm/os-verify-lock.sh. Each count is the runner's own summary line.turbo run buildover./packages/*,./packages/*/*and./examples/*gave 73 successful, 73 total.@objectstack/spec:vitest run --project localgave 532 files, 15667 passed, 2 todo (round 2: 15663; the 4 new offer-side tests).--project repogave 35 files, 602 passed.field-currency-scale-refused.test.tsalone: 12 passed (8 from round 2, 4 new).@objectstack/spectypecheckexited 0.check:test-typecheckheld 53 files, 255 errors and 142 pinned signatures, the same as rounds 1 and 2.check:type-check-coverageexited 0, so the package'stsconfigreaches the edited test file.@objectstack/platform-objectstranslation suites gave 6 files, 137 passed;@objectstack/lintvalidate-predicate-path-refs.test.tsgave 1 file, 54 passed;@objectstack/metadata-protocolprotocol.meta-types-*gave 3 files, 38 passed.@objectstack/objectql(on40ef043003: 309 files, 5196 passed; 1 file, 5 passed;typecheckexit 0 with 40 files, 234 errors and 65 pinned signatures held),@objectstack/example-crm,@objectstack/example-showcase, and round 1'splatform-objects,metadata-core,driver-sqlandservice-automationsuites. No file in those packages changed since40ef043003. 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.zodand../system/metadata-form-registrythrough relativesrcimports.Round 3, the offer.
scripts/ablation-replace.mjsrestored the old row,visibleWhen: "data.type in ['number','currency','percent']", onobject.form.ts. The landing was proved on disk: new-row anchor 1 → 0, old row 0 → 1, blob6e87b93283→295afbe69f. Thenfield-currency-scale-refused.test.tsran: 2 failed | 10 passed. The two failures are the firing case ("expected true to be false") and the class guard (it receivedobject:fields.scalewhere it expected none). The lit CONTROLS, thenumberandpercentdark controls and all 8 round-2 tests stayed green. Restored: blob6e87b93283== HEAD,git diff HEADempty, andgit status --porcelainempty.Round 2's two legs, on
40ef043003:field.zod.tswas restored to its round-1 blob0c24baa382, 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. Thenfield-currency-scale-refused.test.tsran: 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: blobd6ada97894== HEAD, andgit diff HEADis empty.scripts/ablation-replace.mjsappended "Move the value tocurrencyConfig.precisioninstead." to the new message. Anchor 1 → 0, blobd6ada97894→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, andgit diff HEADis 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 messagetoContainon the other key and a control named "the remedy target parses". Here is what replaces them:currencyConfignorprecision. It runs on all three firing paths: the designer shape, the shape beside acurrencyConfigblock, and theField.currency()+ObjectSchemapath.scale, adds nothing, and the result is accepted.currencyConfig.packages/spec/src/data/currency-precision-iso4217.test.ts:293names that key only asCurrencyConfigSchema's ISO-contradiction issue path. It is not a remedy pin and is unchanged.currencyConfighits that remain (git grepon HEAD40ef043003, unchanged atd7740c324e)c8399867b8..HEAD): 7 hits. None names that key as a remedy or as a decimal-places carrier.currencyConfigblock. Its fix is still the deletion.field-zoo.object.ts:53is in the diff only becausescale: 2was deleted from that line. ItscurrencyConfig: { …, precision: 2 }isorigin/main's, unchanged.field-currency-scale-refused.test.tshas 5 hits: two absence assertions, one test title, and two fixtures with acurrencyConfigblock and noprecision(the firing case and the remedy-follow control). Round 3 added none.c8399867b8and HEAD):fields.mdx1,types.mdx2 and the three reference pages 2 each. The reference hits are generated fromCurrencyConfigSchema.account.object.ts2,field-zoo.object.ts1.field.test.ts34 andfield.zod.ts10: theCurrencyConfigSchemadefinition and tests, the ISO check, and thecurrency-is-not-a-field-key refusal.registry.ts1, in another entry.domain:specseat.Test fixtures re-judged (round 1)
These fixtures pinned a currency field with
scale, ormax_scaleon currency:packages/spec/src/data/field.test.ts: "should accept number field with precision and scale" spelledtype: 'currency'. It now spellsnumber, a spelling change only.packages/spec/src/data/object.test.ts: the account fixture'sannual_revenuelostscale: 2.packages/spec/src/data/field-scale.test.ts: the resolver's "declared scale wins" example moved fromcurrencytoslider.packages/objectql/src/validation/record-validator.test.ts: the finding(spec,objectql): ruling B's percent storage derivation is NOT live —max_scalehas no percent arm and thepercentScaleOfdocblock is silent aboutscale#19320 CONTROLS loop droppedcurrency. A new [finding]FieldSchema.scaleon acurrencyfield is offered to authors by the field designer, ignored by every display face, and still enforced on writes — half-live in the direction that surprises #19629 block holds the three ruled pins, and its header now cites ruling 乙 for the unconstrained write allowance.packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts: the Flow field expressions can call no function butNOW()/TODAY()— every other identifier is rewritten tonull, so a computed money value can never be rounded to its field's declaredscale#11060 oracle needs a livescalegate, so itstotalfield moved fromcurrencytonumber.packages/drivers/driver-sql/src/sql-driver-external-unprovisioned-sort-anchor.test.tsandpackages/metadata-core/test/injected-column-provenance.test.tsmirror the showcase external customer. Both lostscale: 2.Changeset
.changeset/19629-currency-scale-retired.mdsets@objectstack/spec: minorand@objectstack/objectql: minor, with a BREAKING banner. Per AGENTS.md,Clause-②: no (narrowing)is breaking, andcheck-changeset-no-majorrefusesmajor. Round 2 reworded it to ruling 乙:Round 3 added one sentence to its
@objectstack/specparagraph, because the form change is visible in Studio: "Studio's object editor no longer offersscaleon a currency field: the fields grid of theobjectFormthis package registers inMETADATA_FORM_REGISTRYnow shows it only fornumberandpercent." 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-registrationreads "[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
docs/qa/platform-checklist/areas/records-forms.json: the field-zoo fixture line still reads "f_currency scale 2". ItsknownGapstext, which saysscaleis not enforced on write, has been stale since A number field's declaredscaleis never enforced — values with more decimals are accepted and stored verbatim (min/max on the same field are enforced) #7501. This is checklist-owned text and is left for that seat.precision: 18on a currency field (schema.mdx×3,types.mdx). They are meant as DECIMAL(18,2) total digits, but the pinned edit widget reads field-levelprecisionon currency as fraction digits. This predates the PR. It is the surface of ruling 乙 item 3's second finding.data-modeling/fields.mdx("Currency Configuration") andprotocol/objectql/types.mdxshowcurrencyConfig: { precision: 2, … }as a currency's configuration. This predates the PR and is the surface of ruling 乙 item 3's first finding.FieldSchema's alias table renamesdecimals/decimalPlacestoscale. On a currency field that rename now leads to this refusal, and its remedy tells the author to delete the key.scale: 2on a currency quote field (hotcrm#1206, cited by the flow oracle). That becomes a refused parse downstream, and the migration entry prescribes the deletion.currencyConfig.precision/ ISO minor units", which is ruling B's wording. Under ruling 乙 only the ISO half holds. Rewording that card belongs to thedomain:uiseat.CurrencyConfigSchema(field.zod.ts:420) acceptscurrencyConfig: { scale: … }and renames it tocurrencyConfig.precision, the key [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 records as read by nothing. This predates the PR and is notFieldSchema.scale; it goes wherever [finding] currencyConfig.precision is declared and validated against ISO 4217, but no renderer or runtime reads it — an ADR-0049 enforce-or-remove case, filed on ruling 乙 on #19910 #19992's enforce-or-remove decision takes that key.precisionrow (field.form.ts:78) is shown on currency with the help text "Decimal places (e.g., 2 for $10.50)", whileFieldSchema.precision's describe (field.zod.ts:1211) says "Total digits" andobjectForm's ownprecisionrow says "Total digits". That is aprecisionrow, which this PR does not touch; it is the spec-side neighbour of [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.numeric-column-representation.ts:49-52records a write-seam measurement in which acurrencyfield declaringscale: 2was refused. After this PR the seam no longer readsscaleon currency, so the docblock describes a past reading. It is an internal comment, not an offer, and it is left alone.Gates, on HEAD
d7740c324enode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 120 families from this worktree. Every one was run with its exit code captured before any pipe, and all 120 exited 0. The--ranreconciliation 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/maine8f163fc3ais 23 commits past the merge base, and 18 derivation inputs changed in that range (among them.github/workflows/lint.ymlandpackage.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: nomajorbump. The level axis reads this PR body, so it is NOT MEASURED locally.d7740c324e, read at 2026-09-24T18:25Z: 35 check-runs, 33successand 2skipped(Console Pin Gate,Packed-tarball smoke (opt-in)), none failed. All seven required contexts andCheck Changesetreadsuccess.eslint --no-inline-config --format jsonover the 25 changed.ts/.mjsfiles (c8399867b8..HEAD, round 3 addsobject.form.ts) reported 25 files, 0 errors, 0 warnings and no ignore notices. The narrowing holds becauseeslint.config.mjsnever enables type-aware linting (its own comment at lines 326-328: noparserOptions.project, no typed rules), so this diff cannot move any untouched file's verdict. The fullpnpm lintbelongs to CI.Generated by Claude Code