Skip to content

fix(service-analytics): a measure column takes the source field's currency only under currencyMode 'fixed' (#20091) - #20119

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20091-analytics-currency-mode
Sep 25, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-20091-analytics-currency-mode

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20091
Clause-②: no

What was wrong

AnalyticsServicePlugin passes each source field's metadata to the service through sourceFieldMeta. That hook passed on currencyConfig.defaultCurrency without reading currencyConfig.currencyMode. queryDataset resolves a monetary measure column's currency in this order: the measure's own currency, then that relayed value, then ExecutionContext.currency. So a dynamic field's defaultCurrency showed on the dataset result where the tenant currency belonged. The spec says otherwise:

  • CurrencyConfigSchema defaults currencyMode to 'dynamic'. Its own precision refinement treats defaultCurrency as the field's one currency only under 'fixed'.
  • The FieldSchema guidance says a fixed currency is currencyConfig: { currencyMode: 'fixed', defaultCurrency: … }, and "A field without one uses the tenant default at runtime".

objectstack-ai/objectui#10461 changed the renderer's resolveFieldCurrency to read defaultCurrency only under 'fixed'. The renderer reads an explicit column currency first, though, so the analytics faces kept overriding that rule.

Measured before the fix (commit 9a98c59b44: base fa00ebf447 plus the new test file only), and after

AnalyticsServicePlugin booted over a real ObjectQL engine on SqliteWasmDriver, with the object registered on the engine. ExecutionContext.currency = 'USD', and every authored defaultCurrency is EUR. Each cell is the result column's currency on the live path / the draft-preview path of queryDataset:

case raw registration, before parsed registration (ObjectSchema.parse), before after the fix (both registrations)
{ currencyMode: 'fixed', defaultCurrency: 'EUR' } EUR / EUR EUR / EUR EUR / EUR
{ currencyMode: 'dynamic', defaultCurrency: 'EUR' } EUR / EUR EUR / EUR USD / USD
{ defaultCurrency: 'EUR' } (no mode) EUR / EUR EUR / EUR USD / USD
{} (parses to dynamic + the schema's CNY) USD / USD CNY / CNY USD / USD
currency field, no currencyConfig USD / USD USD / USD USD / USD
measure currency: 'JPY' over the dynamic field JPY / JPY JPY / JPY JPY / JPY
measure currency: 'JPY' over the fixed field JPY / JPY JPY / JPY JPY / JPY
non-monetary control (number field) none / none none / none none / none

Bold cells break the spec rule. The "before" columns are also in the message of commit 9a98c59b44. The "after" column was measured on head 131de3a37a with the same probe.

What "no mode" looks like at the relay. SchemaRegistry.registerObject stores what it is handed and does not parse. A raw registerObject call therefore arrives with no currencyMode key at all. The declared-stack path parses first, which fills in currencyMode: 'dynamic', and fills in defaultCurrency: 'CNY' when the author wrote none. Measured with ObjectSchema.parse: { defaultCurrency: 'EUR' } parses to {"precision":2,"currencyMode":"dynamic","defaultCurrency":"EUR"}, and {} parses to {"precision":2,"currencyMode":"dynamic","defaultCurrency":"CNY"}. The fix compares currencyMode === 'fixed', so both shapes fall to the tenant default. The relay supplies no default of its own.

Every reader of sourceFieldMeta().defaultCurrency in the package: one. It is enrichResultColumns (analytics-service.ts, the currency chain). The live and draft-preview paths of queryDataset both reach it, and the same loop covers __compare columns. A grep over src/ for defaultCurrency and for currenc finds nothing else:

  • sourceFieldMeta's other readers take only .type (the compile gate at registerDataset, the time-dimension type, measureResultType) or max (percentScaleOf).
  • Dimension columns get only a label.
  • Drill metadata and dimension value labels carry no currency.
  • The package has no export face.
  • The cube face (AnalyticsService.query and its SQL echo) never writes a column currency. The suite pins that as measured.

The fix

  • plugin.ts: the relay answers defaultCurrency only when currencyConfig.currencyMode === 'fixed', and undefined otherwise. The rule is decided at the one producer, so the one reader inherits it. An explicit measure currency still wins.
  • No reader needs the raw defaultCurrency, so no second key was added. The public AnalyticsServiceConfig.sourceFieldMeta return type is unchanged. Its TSDoc now says what defaultCurrency means there (the field's FIXED currency) and what a host writing its own sourceFieldMeta must answer.
  • No packages/spec change and no objectui change.

Tests

New packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts, 19 cases. Each case runs end to end through the plugin's relay, the real engine and driver, and the queryDataset result, under both registration shapes. Each asserts the live and the preview path. The cases:

  • fixed, dynamic, no mode, empty config, no config;
  • an explicit measure currency over a dynamic field and over a fixed one;
  • a non-monetary control;
  • two fixture controls: the shape really differs, and the live rows really came from the driver;
  • the cube-face pin.

Before the fix: 5 failed, 14 passed. After: 19 passed. The failures before the fix were the 5 bold cells above:

AssertionError: expected 'EUR' to be 'USD'   (dynamic, raw + parsed; no mode, raw + parsed)
AssertionError: expected 'CNY' to be 'USD'   (empty config, parsed)
Tests  5 failed | 14 passed (19)

Ablation. Every negative pin was ablated through node scripts/ablation-replace.mjs in WRAP mode, after the fix was committed. Each mutation was proven on disk: the anchor count went 1 to 0 and the blob changed. Each restore was proven too: the blob equals HEAD and git diff HEAD is empty.

leg mutation red
A plugin.ts: the pre-fix relay, which passes defaultCurrency whenever present 5 failed / 14 passed: dynamic ×2, no mode ×2, empty config (parsed)
B plugin.ts: a relay that fills in the schema's CNY itself and ignores mode 8 failed / 11 passed: dynamic ×2, no mode ×2, empty config ×2, no config ×2
C analytics-service.ts: the cube face writes a currency on its columns 13 failed / 6 passed, including the cube-face pin
D analytics-service.ts: every measure treated as monetary 2 failed / 17 passed: the non-monetary control ×2
E test fixture: the raw shape is parsed too 1 failed / 18 passed: the shape control

Leg C's first attempt did not run. Its replacement text still contained the anchor, so ablation-replace refused the leg before running anything ("the anchor count moved 1 -> 1"), and the file was restored. It was re-run with a replacement that consumes the anchor, and that run is the one in the table.

Package suite on the merged head 131de3a37a: pnpm --filter @objectstack/service-analytics test, 125 files / 2905 tests passed. pnpm --filter @objectstack/service-analytics run typecheck exits 0. tsc --listFiles includes the new test file (1 hit; the control plugin.ts also has 1).

Changeset

.changeset/20091-analytics-currency-mode.md: @objectstack/service-analytics, patch, with a Clause-②: no line. It is a fix to a released package that adds or removes no authorable key, export or type member. check-changeset-no-major --base origin/main exits 0, and so does check-adr-0087-registration --base origin/main: the changeset is not declared breaking, so no ADR-0087 marker is owed.

Acceptance notes

  • The code labels this chain "ADR-0053", but ADR-0053 says nothing about currency. ADR-0053 is date/datetime semantics: grep -ci currenc on it gives 0, and the control datetime gives 73. This PR corrects the label on the lines it rewrites (plugin.ts relay comment, analytics-service.ts TSDoc and chain comment). It does not touch the same label in preview-column-enrichment.test.ts (the header table and two test names). No carrier: noted, not filed.
  • { currencyMode: 'fixed' } with no code differs by registration door. Not measured, a source read. A raw registration relays undefined, so the column gets the tenant default. A parsed one relays the spec's defaulted CNY, which the spec's own refinement treats as the field's currency in fixed mode. The relay follows the registered shape on purpose. Supplying the spec default here would be a second copy of it.
  • A host that writes its own sourceFieldMeta must apply the same rule. A type cannot express it, so the TSDoc and the changeset state it.

Local verification (head 131de3a37a, after the origin/main merge)

  • node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derived 61 commands from this diff, and all 61 exit 0. --ran (each command recorded with its exit code) answers: "61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN".
    • The derivation added six families to the dispatch-time list: check:engine-double-contract, check:objectql-double-limit, check:query-options-erasure, check:type-check-coverage, check:type-check-debt and check:where-matcher.
    • check:dual-build-cjs-loads and check:type-check-debt first answered PREREQUISITE NOT MET (exit 3), which is not a measurement. Both exit 0 after turbo run build --filter='./packages/*' --filter='./packages/*/*' (72/72 tasks).
  • GITHUB_TOKEN=… node scripts/check-issue-citations.mjs exits 0: 5 citations, 3 resolve, 2 are cross-repo.
  • Lint, narrowed to the touched files (pnpm lint over the whole repo is CI's job):
    • eslint --no-inline-config --format json on the 3 touched TypeScript files exits 0. The JSON reports 3 files, with 0 errors and 0 warnings.
    • eslint --print-config puts all 3 files in the linted population: 6 rules each on the two sources, and 5 on the test file.
    • Linting only these files misses nothing. eslint.config.mjs enables no type-aware linting (no parserOptions.project), and its local rules are scope-based and per-file. A diff to these files therefore cannot change a verdict on any untouched file.

Generated by Claude Code

…ncyMode, end to end (#20091)

Measurement, recorded BEFORE the fix. Tree: base fa00ebf plus this
test file only; AnalyticsServicePlugin booted over a real ObjectQL engine
on SqliteWasmDriver; ExecutionContext.currency = USD; every field's
authored defaultCurrency = EUR. Currency on the queryDataset result
column, live path / draft-preview path:

  case                                         raw reg.     parsed reg.
  fixed, EUR                                   EUR / EUR    EUR / EUR
  dynamic, EUR                                 EUR / EUR    EUR / EUR    <- defect
  no mode, EUR                                 EUR / EUR    EUR / EUR    <- defect
  currencyConfig {} (parsed => dynamic CNY)    USD / USD    CNY / CNY    <- defect (parsed)
  no currencyConfig                            USD / USD    USD / USD
  explicit measure currency JPY (dynamic fld)  JPY / JPY    JPY / JPY
  explicit measure currency JPY (fixed fld)    JPY / JPY    JPY / JPY
  non-monetary control (number field)          none / none  none / none

Five of the committed cases are red on this tree; the fix follows.

Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF
Co-authored-by: Claude <noreply@anthropic.com>
…rrencyMode 'fixed' (#20091)

The plugin's sourceFieldMeta relay passed currencyConfig.defaultCurrency
through regardless of currencyMode, so queryDataset stamped a dynamic
field's defaultCurrency (or the schema's placeholder CNY on a parsed
empty config) onto the result column where the tenant default belongs.
Only currencyMode 'fixed' pins a field's currency (CurrencyConfigSchema);
the relay now answers undefined otherwise, and the chain falls through
to ExecutionContext.currency. An explicit measure currency still wins.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-analytics, touching 3 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/plugins/packages.mdx (via AnalyticsServicePlugin (symbol, a top-level class))
What this run could not see
  • 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 — 9 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 7c1039b388f46222b0029d93a03cefa9fbf5f67f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7c1039b388f46222b0029d93a03cefa9fbf5f67f

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

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 25, 2026 06:42
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 6780e34 Sep 25, 2026
35 of 36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20091-analytics-currency-mode branch September 25, 2026 07:01
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… text states the fixed condition (objectstack-ai#20179)

Part of objectstack-ai#20126
Clause-②: no

This PR covers the non-governed sites of the currency-mode class-closure
card. It does not close the card. The remainder is listed at the end:
the Tier H skills site and the enumerating pin.

## What was wrong

`CurrencyConfigSchema` declares two modes. With `currencyMode: 'fixed'`,
the field has one currency, `defaultCurrency`. With `dynamic`, the
default, the field has no currency of its own and shows the tenant
default currency. Every runtime reader follows that rule:

- `packages/spec/src/data/field.zod.ts`: the precision refinement judges
`defaultCurrency` only under `'fixed'`. The `FieldSchema` guidance says
"A field without one uses the tenant default at runtime".
- `packages/services/service-analytics/src/plugin.ts`
(`sourceFieldMeta`): relays `defaultCurrency` only under `'fixed'`, as
landed by the runtime member of this family (objectstack-ai#20119).
- objectui's `resolveFieldCurrency`, which the field faces call: reads
`defaultCurrency` only under `'fixed'`. Read from objectui's changeset
`10422-currency-mode-dynamic-tenant-default.md` at its `main`
(`a2f361b`). With no tenant currency set, the amount shows as a plain
number.

Four kinds of text still said otherwise:

1. **The spec describe** (card site 1). It called `dynamic` "user
selectable". No key, stored value or runtime path offers a per-record
currency choice. The value is a bare number (ADR-0104 D1), and the code
lives in field config. The describe ships in the published
`@objectstack/spec` and in the generated
`content/docs/references/data/field.mdx` (2 rows).
2. **`content/docs/api/data-api.mdx`** (card site 3). It named "the
ADR-0053 currency chain" with no mode condition. ADR-0053 is the
date/datetime record and never mentions currency.
3. **service-analytics test text.** Two test files still called the
chain "ADR-0053": the `preview-column-enrichment.test.ts` header table
and two `it` titles, and a `query-dataset.test.ts` section comment. The
objectstack-ai#20119 PR noted the test-file labels and left them as they were.
4. **Hand-written `content/docs/**` lines** that taught
`defaultCurrency` without the `fixed` condition. The census is below.

## What changes

- `field.zod.ts`, **`currencyMode` describe only.** It now says what
each mode does. `fixed`: the field has one currency, `defaultCurrency`.
`dynamic` (the default): no field currency, amounts show in the tenant
default (`localization.currency`, or a plain number when that is unset),
and `defaultCurrency` is not read. Neither mode is a per-record choice.
`gen:docs` regenerated the two `field.mdx` rows. No key, value, default
or parse output changes. `.changeset/20126-currency-mode-describe.md` is
an `@objectstack/spec` patch.
- **`data-api.mdx`.** The ADR-0053 citation is gone. The callout now
states the chain as it runs: measure `currency`, then the source field's
`currencyConfig.defaultCurrency` only when that field is `currencyMode:
'fixed'`, then the tenant default. It also says where the chain runs. It
runs on the dataset query (`POST /analytics/dataset/query`). The cube
query this page documents (`POST /analytics/query`) mints no column
`currency`, and `currency-mode-relay.test.ts` pins that cube-face fact.
- **service-analytics, comments and test titles only.** The header table
row, the two `it` titles, one in-test comment ("no field default" now
reads "no fixed field currency") and the `query-dataset.test.ts` section
comment now describe the chain with the fixed condition. The old
ADR-0053 label is gone. No assertion or runtime line changes.
- **Census fixes** (table below): `validation-rules.mdx`,
`common-patterns.mdx` and `protocol/objectql/types.mdx`.

## Census: every hand-written `content/docs` line naming
`defaultCurrency` (at base `84880f9`)

Rule, from the card: the line must also carry `fixed`, or say it is not
the displayed currency under `dynamic`. The census covers 13 lines, the
same per-file counts the dispatch named (3 / 4 / 1 / 2 / 3). `releases/`
is never edited.

| file:line | verdict | why |
|:---|:---|:---|
| `data-modeling/field-types.mdx:156` | pass | the mode row states
`fixed` and `dynamic` |
| `data-modeling/field-types.mdx:157` | pass | "under `fixed`; not the
displayed currency under `dynamic`" |
| `data-modeling/field-types.mdx:160` | pass | the example carries
`currencyMode: 'fixed'` |
| `data-modeling/fields.mdx:77` | pass | `currencyMode: 'fixed'` line |
| `data-modeling/fields.mdx:79` | pass | "the column's currency under
'fixed'" |
| `data-modeling/fields.mdx:86` | pass | "not the displayed currency"
under `dynamic` |
| `data-modeling/fields.mdx:87` | pass | "`fixed` pins the column to
`defaultCurrency`" |
| `data-modeling/validation-rules.mdx:156` | changed | "3-character
currency code" with no mode condition. It now adds "the field's currency
under `fixed`; not the displayed currency under `dynamic`" |
| `getting-started/common-patterns.mdx:53` | changed | `{ precision: 2,
defaultCurrency: 'USD' }` has no mode, so it is `dynamic`, and the
budget shows the tenant currency, never USD. Added `currencyMode:
'fixed'` |
| `getting-started/common-patterns.mdx:82` | changed | same, in the
map-format tab |
| `protocol/objectql/types.mdx:297` | changed (line shape) | The YAML
block already carried `currencyMode: fixed` on :296, but this line did
not carry it. Added the comment "the field's currency under fixed" so
the line meets the pin |
| `protocol/objectql/types.mdx:308` | changed | "The currency code lives
once on the field definition (`currencyConfig.defaultCurrency`)" holds
only under `fixed`. It now states both modes |
| `protocol/objectql/types.mdx:315` | pass | carries `fixed`
(`currencyMode: fixed \| dynamic`). Left as it was, because the callout
above now states the condition |

Two more lines in `validation-rules.mdx` sit in the same `currency`
table and belong to the same class, although neither names
`defaultCurrency`:

- `:155`, the `currencyMode` row, repeated card site 1's "`dynamic`
(user-selectable)" wording. It now matches the describe and adds this
page's validation fact: under `fixed`, an authored `precision` must
agree with the currency's ISO 4217 fraction digits.
- `:158` said "The per-record currency **code**", which contradicts
ADR-0104 D1. It now says the code is never stored per record.

Both sit inside a census-hit file and table, and both are mechanical
rewrites in the wording of the accepted objectstack-ai#20118 / objectstack-ai#20120 pages.

Pin readings after this PR, over the card's own scopes. The census
script ran from the scratchpad and was not committed.

- The `defaultCurrency` line rule over `content/docs/**/*.mdx` (without
`releases/`): 5 failing at base, **0** at head.
- `ADR-0053` within five lines of `currenc`, over `content/docs/**`,
`packages/services/service-analytics/src/**` and `skills/**`: 5 hits at
base (`data-api.mdx:467`,
`preview-column-enrichment.test.ts:51/:282/:288`,
`query-dataset.test.ts:125`) plus the skills site, and **1** at head.
The one left is `skills/objectstack-ui/rules/dashboards.md:308`, which
is the Tier H remainder.

## Tests and gates

All readings below were taken on head `25b085386`. That head is this
branch plus a merge of `main` at `e2c4e125f`, and the merge brought
nothing into this PR's files. Before the gates ran, all 71 package build
tasks passed (`turbo run build --filter='./packages/*'
--filter='./packages/*/*'`, 71/71).

- **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` derived 109 commands. Each was run
and its exit code recorded, and all 109 exit 0. The `--ran`
reconciliation answers: "109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN".
The derived set includes `check:docs`, `check:generated` (15 of 15
current), `check:api-surface`, `check:authorable-surface`,
`check:doc-authoring`, `check:nul-bytes`, `check:skill-examples`,
`check:dual-build-cjs-loads` and `check:type-check-debt`.
- **Roster gates.** Six roster families keep their roster in a directory
this PR touches, and all six exit 0: `check-changeset-fixed`,
`check:meta-url-spelling`, `check:spec-changes`, `check:authz-resolver`,
`check:error-code-casing` and `check:filter-alias-parity`.
- **This body.** Run against this text, `check-partof-closing-keyword`
exits 0.
- **`@objectstack/spec`.**
- `pnpm --filter @objectstack/spec test`: 540 files, 15820 passed, 2
todo.
  - `pnpm --filter @objectstack/spec run typecheck`: exit 0.
- No spec test pins the old describe text. The phrase "user selectable"
appears only in the describe and its generated page.
- **`@objectstack/service-analytics`.**
- `pnpm --filter @objectstack/service-analytics exec vitest run
--maxWorkers=2` on the two edited files: 2 files, 44 tests passed.
- `pnpm --filter @objectstack/service-analytics run typecheck`: exit 0.
- `tsc --listFiles` includes both edited test files (2 hits; the control
`src/plugin.ts` has 1).
- **Lint, narrowed to the 3 touched TypeScript files** (`pnpm lint` over
the whole repo is CI's run):
- `eslint --no-inline-config --format json` exits 0, and the JSON
reports 3 files, 0 errors and 0 warnings.
- `eslint --print-config` puts the files in the linted population, with
5 rules on.
- `eslint.config.mjs` enables no type-aware linting (no
`parserOptions.project`), so a change to these files cannot move a
verdict on any untouched file.
- **No ablation.** The diff changes text only: a describe, docs,
comments and test titles. No assertion or runtime line changes, so no
negative pin exists to ablate.

## Acceptance notes

- **The `defaultCurrency` describe is left as it was.** It reads
"Default or fixed currency code". The claimed surface is the
`currencyMode` describe only. The generated row meets the card's pin,
because it carries `fixed`, and the rewritten `currencyMode` row beside
it now states the condition. "Default" can still read as a dynamic-mode
default. That belongs to this family and is noted for the closure card,
not changed here.
- **More ADR-0053-for-currency sites exist outside the card's pin
scopes.** They are the same family and are not in this PR's claimed
surface:
- `packages/spec/src/contracts/analytics-service.ts:80`: the published
TSDoc on `AnalyticsResult.fields[].currency` reads "ADR-0053 currency
chain … source-field default → tenant default", with no mode condition.
It ships in the `@objectstack/spec` typings.
- `packages/spec/src/data/percent-scale.ts:14`: a comment saying
"mirrors the ADR-0053 currency chain".
- `packages/spec/liveness/dataset.json` (the `currency` row's evidence
text): "the ADR-0053 chain … the source field's `defaultCurrency`".
- The card's pin scopes (`skills/**`, `content/docs/**`,
`service-analytics/src/**`) do not reach `packages/spec/src/**`. These
are noted for the closure card.
- **A strictly line-based pin would go red on semantically correct skill
lines.** `skills/objectstack-data/rules/field-types.md` lines :55, :183,
:184, :191 and :192 were corrected and accepted with objectstack-ai#20120. Each states
the condition across a wrapped sentence or a two-line comment, but none
carries `fixed` on its own line. Whoever writes the enumerating pin must
judge a sentence or block, not a single line, or reshape those lines in
the same Tier H PR.
- **`data-api.mdx` makes a wider claim than currency.** The callout says
`label`, `format` and `percentScale` are resolved server-side on this
page's `fields[]`. That is a source read and was not measured. Both cube
strategies push only `name` and `type`
(`native-sql-strategy.ts:1247/:1252`,
`objectql-strategy.ts:1960/:1964`), and the ADR-0021 column enrichment
runs only on `queryDataset`. Only the currency sentence was in scope and
was corrected. Carrier: none, noted and not filed.
- **Three `query-dataset.test.ts` titles are left as they were.** They
say "inherits the field defaultCurrency", "a currency field with no
defaultCurrency" and "wins over the field default". They cite no ADR, so
they are outside the claimed surface. The section comment above them now
says that `sourceFieldMeta().defaultCurrency` is the field's fixed
currency.

## Remainder of objectstack-ai#20126 (not in this PR)

- `skills/objectstack-ui/rules/dashboards.md:304`–`:308`: the
unconditioned chain and the ADR-0053 citation. This is Tier H and gets
its own PR, per triage execution note 1.
- The enumerating pin: a test in the owning package, added by whichever
PR closes the last site (no new `check-*` gate). See the line-based
caveat above.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…tstack-ai#20126) (objectstack-ai#20415)

Fixes objectstack-ai#20126
Clause-②: no

Remainder (c) of the currency-mode class-closure card: the family's one
enumerating pin. Remainder (b) landed in PR objectstack-ai#20217, the skills half
(objectstack-ai#20196) in PR objectstack-ai#20256, and the runtime member (objectstack-ai#20091) in PR objectstack-ai#20119, so
this test-only PR closes the card.

## What the pin is

One new test in spec's `repo` vitest project:
`packages/spec/src/data/currency-mode-family-closure.pin.test.ts`. The
maintainer's ruling on objectstack-ai#20088 asked for one enumerating pin in the
owning package and ⛔ no new `check-*` gate. `packages/spec` owns
`CurrencyConfigSchema`, and its repo project already walks `skills/**`
and `content/**`. The pin enumerates the corpus, not the sites. A new
site anywhere in the radius reddens it, as well as a regression of an
old one.

- **Rule A.** Every `defaultCurrency` in `skills/**/*.md` and
`content/docs/**/*.mdx` sits in a unit that carries `fixed`, or that
says it is not the displayed currency (or not read) under `dynamic`.
- **Rule B.** The date record's number within five physical lines of
`currenc` (any case) counts 0. The matcher reads `ADR-0053`, `ADR 0053`
and the `adr/0053-` file link. The roots are `skills/**`,
`content/docs/**` and `packages/services/service-analytics/src/**`. Per
the family's appended sites 5-10 (ACCEPT `5853407641`, triage
`5853734589`), they also include `packages/spec/src/**`,
`packages/spec/liveness/**` and `examples/**`: non-code files anywhere,
plus `.ts` under each app's `src/`.
- **Runtime member.** It is objectstack-ai#20091's own pin,
`packages/services/service-analytics/src/__tests__/currency-mode-relay.test.ts`,
on `main` (`git merge-base --is-ancestor 6780e34 HEAD` exit 0). The
closure pin only checks that this file still stands, and ⛔ does not
re-assert the resolver.
- **Site table.** Each family site (1-10, the three pages objectstack-ai#20090 landed,
and 11-14) is mapped to the leg that guards it. The test asserts each
file's membership in each corpus, so if a site moves out of the radius,
the pin goes red.

## Outside the corpus, and why

- **Released entries.** This covers every `CHANGELOG.md` and
`content/docs/releases/**`. The seat ruled sites 11-14 (the released
17.0.0 entries) accepted history, since a released entry records what
shipped (claim `5866047669`). The two package CHANGELOGs sit outside
every root by construction. `examples/*/CHANGELOG.md` is inside a root
and is skipped by name. The test asserts both exclusions, with a live
control proving the by-name skip has something to skip.
- **The pin's own file.** It quotes both rules and their fabricated
controls.

## The three design facts (`5855122068`), each answered in the pin's
header

1. **Test titles.** Rule A reads published prose only (`.md` / `.mdx`),
so no test title is judged by it. The `field.test.ts` title stays
outside by corpus, not by exemption. Rule B does read test files inside
its roots.
2. **Negation versus citation.** Rule B does not try to tell them apart:
a negation within the window counts too. Currency text has no reason to
name the date record; the pin's message says to cite ADR-0104.
3. **Wrapped keys.** Both rules read logical text. A key hyphen-wrapped
at a line end (`default-` / `Currency`, `ADR-` / `0053`) rejoins after
the next line's comment prefix is stripped.

**Unit for rule A: not the line.** A strict line reading reds on 5
correct, accepted lines in
`skills/objectstack-data/rules/field-types.md` (ACCEPT `5853407641`'s
pin note). Instead, the unit is:
- in prose, the sentence across wrapped lines;
- in a table, the row;
- in a fenced comment, the sentence of its comment run;
- in fenced code, the innermost object literal holding the key, or the
sibling mapping in YAML.

So one `fixed` field in a fence never vouches for a neighbour's bare
`defaultCurrency`. There is one shape exemption: a parenthesised key
list that also names `currencyMode`, `(currencyMode, defaultCurrency)`.
It names keys and teaches no reading; this is `field-types.md:55`.

## Measured on this branch's base `dcd3bceaa` (assumption 1 holds)

- **Rule A:** 445 files in the corpus. 34 `defaultCurrency` occurrences
in 11 files; 0 failing, 1 of them the key-list exemption.
- **Rule B:** 2550 files in the corpus, 0 hits. 65 date-record lines
remain, all outside the window (date uses).
- **No live text site:** no text site was edited in this PR.

## Declared radius (AGENTS.md: a test that reads outside its package is
spelled so the gate can see it)

Rule B's example-code leg is new radius for
`@objectstack/spec#test:repo`. It is declared in two places:
- `scripts/cross-package-test-inputs.mjs` gets `examples/*/src/**/*.ts`;
- `turbo.json` gets the matching `$TURBO_ROOT$/...` input.

The glob is deliberately ⛔ not `examples/**/*.ts`. That glob covers the
CRM example's smoke test, which is the dispatch-gates self-test's
outside-packages specimen. The declaration comment states that the glob
reaches the two translation tests inside the todo app's `src/`. The
pin's site table quotes the showcase dataset's path, which holds the
glob on the roster. `check:cross-package-test-inputs` and
`check:ci-filter-parity` are both green.

## Verification

All readings are at head `7fc9f86b`, the branch's only commit; every
build and test ran through `scripts/pm/os-verify-lock.sh` (`VERDICT
command-exit 0` each).

- **The pin alone:** `pnpm --filter @objectstack/spec exec vitest run
--project repo --maxWorkers=2
src/data/currency-mode-family-closure.pin.test.ts` → `Test Files 1
passed (1)` / `Tests 6 passed (6)`.
- **Ablation, one leg per rule** (from the committed state,
`scripts/ablation-replace.mjs` in wrap mode, under the lock). The
expected direction was red, and both legs went red:
- Leg A drops ` (only under currencyMode: 'fixed')` from
`skills/objectstack-ui/rules/dashboards.md`, site 2's regression. Anchor
1 → 0, blob `570341f3` → `8e0c44f3`. Rule A goes red with exactly
`skills/objectstack-ui/rules/dashboards.md:307`: `Tests 1 failed / 5
passed`. Restored: blob equals HEAD `570341f3`, and `git diff HEAD` is
empty.
- Leg B inserts ` (ADR-0053)` after the key in
`examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts`, site
10's regression and the newly declared example-code radius. Anchor 1 →
0, blob `31348e15` → `1ca43894`. Rule B goes red with exactly
`examples/app-showcase/src/ui/datasets/chart-gallery.dataset.ts:47`:
`Tests 1 failed / 5 passed`. Restored: blob equals HEAD `31348e15`.
- The first leg-B attempt was a no-op and is not a reading: its
replacement embedded its own anchor, and the tool refused (anchor 1 → 1)
and restored before any test ran. It was re-run with an anchor the
replacement does not contain.
- Restored leg: `Tests 6 passed (6)`. `git diff HEAD` is 0 bytes and
`git status --porcelain` is empty.
- **spec, both projects:**
- `vitest run --project repo`: `Test Files 36 passed (36)` / `Tests 640
passed (640)`.
- `vitest run --project local`: `Test Files 561 passed (561)` / `Tests
16517 passed | 1 todo (16518)`.
- `pnpm --filter @objectstack/spec run typecheck` exits 0, and
`check:test-typecheck` is OK. `tsc -p tsconfig.test.json
--listFilesOnly` lists the new file, so the typecheck really reads it.
- **Builds:** `pnpm --filter @objectstack/spec build` exits 0. `turbo
run build --filter='./packages/*' --filter='./packages/*/*'
--concurrency=2` gives `Tasks: 71 successful, 71 total`.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 96 commands over the 4 changed
paths, and every one was run. `--ran` gives `96 derived, 96 run, 0
NOT-MEASURED, 0 UNRUN`. On the first pass, four gates exited 3 because
their prerequisite packages were not built. They exited 0 after the full
build: `check:doc-formula-expressions`, `check:dual-build-cjs-loads`,
`check:lean-entry-closure` and `check:type-check-debt`.
- **Readers of the edited declaration module:** each exits 0.
- `node scripts/pm/dispatch-gates.mjs --self-test`: 1976 cases,
including "no hint of this gate reaches a test file outside
packages/**".
  - `node scripts/check-ci-filter-parity.mjs --self-test`.
  - `node scripts/partition-test-shards.mjs --self-test`.
  - `node scripts/check-published-list-mirrors.mjs`.
  - `node scripts/check-examples-live-imports.mjs`.
- **Lint, narrowed** (CI owns `pnpm lint`):
- `eslint --no-inline-config --format json` on the two lintable changed
files gives 2 files / 0 errors / 0 warnings; `--print-config` gives them
4 and 2 active rules.
- `turbo.json` and `vitest.repo-tests.json` answer "File ignored because
no matching configuration", so they are outside eslint's population.
- `eslint.config.mjs` sets no `parserOptions.project`, so there is no
type-aware linting, and no untouched file's verdict can move.
- **Ships nothing:** spec's `files[]` is `dist`, `json-schema`,
`liveness`, `src/**/*.zod.ts` and so on. A grep of the published paths
for the pin finds 0 files; the control grep for `CurrencyConfigSchema`
in `dist` finds 30. So the diff takes `skip-changeset`.
- **Base:** the branch is one commit behind `origin/main` (`2c310705`).
That commit adds two `packages/spec/src/data` files with no date-record
token, so rule B cannot move on the merge. Not merged here.

## Acceptance notes (not filed)

- `.changeset/*.md` is outside the corpus. It is where released
CHANGELOG text originates, so a pending changeset citing the date record
for currency would not redden this pin before release. Carrier: none;
noted only.
- The `user selectable` wording that site 1 corrected is not a rule of
the card's pin definition, so it is not guarded here.
- Example tests (`examples/*/test/**`, `examples/*/e2e/**`) are outside
rule B's example-code leg, which follows the declared radius above.
- `node scripts/pm/measurement-claim-triage.mjs --self-test` (run as a
reader of the edited module) is red on the base, naming only
`scripts/check-dts-references.mjs:74` (UNTRIAGED), a file this PR does
not touch. No workflow or package script invokes it. Carrier: none.
- The objectui pointer in `5855122068` (objectui
`packages/i18n/src/currency.ts`, `CurrencyField.tsx`) is another
repository and is not read here.

---

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

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 size/m tests tooling

Projects

None yet

2 participants