Skip to content

feat(spec,service-analytics): record-currency analytics — grouped answers name a per-row currency, the cube door refuses an ungrouped amount aggregate, compile guards on measure currencies - #27338

Merged
objectstack-fleet[bot] merged 28 commits into
mainfrom
claude/project-thread-oixqci
Oct 29, 2026

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Requested by Bill · project thread

Fixes #27187
⛔ not released alone — R1: pairs with #27184, #27186, #27187, #27188 (one Version Packages release)

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: follow-up owed — #27189

维护者速读

  • 改了什么: 分析结果里,record 币种金额的度量列新增 currencyField,指向按币种分组的那一列,每行的币种就是该行这列的值;cube 门拒收不按币种字段分组的 sum/avg/min/max(所有路径:数据集与已注册 cube、按对象名的临时查询、草稿预览);数据集编译期新增两条只针对 record 币种的护栏:record 金额上写死度量 currency 拒收(派生度量结果是 record 币种的也拒收),派生度量把 record 金额与别的币种混算拒收。
  • 为什么改: 测量轮探针 C:按币种分组的结果整列只标一个币种,每组都标错;探针 A:跨币种相加得出一个没有币种的数。
  • 风险与回滚: 只新增,不收窄:护栏只管随本期发布的 record 模式,以前能编译的写法一律不受影响;回滚即还原本 PR。契约键走 CONTRACT_REVIEW_TIER 复核。
  • 先看: packages/services/service-analytics/src/cube-record-currency-door.ts(拦截判定)、measure-currency-coherence.ts 的 recordPairDiffers(何时算"与 record 金额币种不同")、packages/spec/src/contracts/analytics-service.ts 新键说明。
  • 两分钟验证: pnpm --filter @objectstack/service-analytics exec vitest run src/__tests__/record-currency-analytics.test.ts,预期 29 passed(两种策略 + 临时查询 + 预览路径 + 护栏)。
  • 需要你做的: 无,席位落地。

What changes

The record currency mode (#27268) has landed on main, and main is brought in by the merge commit 48cdeb43 (no rebase) through scripts/pm/os-regen-merge.sh. The script took main's side of packages/spec/authorable-surface/data.json (f79f10d0), and check:generated then read all 14 generated artifacts up to date, so no regeneration commit was needed. The diff against main is this card's own 10 files, +824 / -8. The contract-review fixes are 0d2291da.

Contract (spec). AnalyticsResult.fields[].currencyField?: string (contracts/analytics-service.ts), and the same key on AnalyticsResultResponseSchema (api/analytics.zod.ts), which the existing type-identity pin in api/analytics.test.ts holds equal to the contract. On a measure column over a currencyMode: 'record' amount, it names the dimension column of the same answer that groups by the amount's code field. Each row's currency is that row's value of that column, and the column carries no currency.

Consumer (service-analytics).

  • plugin.ts relays currencyField through sourceFieldMeta only under currencyMode: 'record', by the same rule that relays defaultCurrency only under fixed.
  • enrichResultColumns sets the pointer from the selected dimensions. It reads the dataset and the declaration, not the rows, so the live path and the draft preview path agree. A record-mode column with no code dimension gets neither key, and a count / count_distinct over the amount gets neither key, grouped or not.
  • cube-record-currency-door.ts runs on all three exits of ensureCube (the inferred cube that an ad-hoc query over the object mints, the augmented cube and the declared one), after the analytics: a config cube min/max over a text column is served at the cube door under fields[] type number; the dataset door refuses the pair by the compatibility table, the cube door consults nothing #21044 field-type door and ahead of both strategies. The ADR-0037 draft-preview evaluator never reaches ensureCube, so its per-query gate (previewService.query) calls the same judgment. A sum / avg / min / max over a record-mode amount whose dimensions do not include the code field (on the same object) is refused INVALID_FIELD / 400 with member, param, cube, field and object. Dataset totals groupings reach it one by one, so a grand total over the amount is refused too.
  • measure-currency-coherence.ts, called from compileDataset (DATASET_INVALID / 400):
    • a measure currency over a record-mode amount, whose currency is per row, and a measure currency on a derived measure whose resolved currency is the record mode;
    • a derived measure (any op) that combines a record-mode amount with a monetary operand in a provably different currency: another code field, a fixed code, an explicit code or the tenant's.
    • Both judge the record mode alone, so nothing that compiled before is refused. The same shapes over fixed fields only (a measure currency contradicting a fixed field, a derived measure over two different fixed codes) are not judged; see Acceptance notes.

Docs. content/docs/api/data-api.mdx lists currencyField among the optional fields[] members, which that page says mirror AnalyticsResultResponseSchema member for member. It also states the record-mode column and the ungrouped-aggregate refusal next to the currency chain (d24c889d, prompted by the docs-drift comment).

Why

ADR-0144 D2/D6 and workload row M0-3: the per-row currency on grouped results, the cube door's refusal, and the guards on the record mode. Measurement round on #8345: probe C (grouped values right, one label per column), probe A (m_mixed_sum 200 with no currency).

Tests

New suite: src/__tests__/record-currency-analytics.test.ts — 29/29

Real ObjectQL engine over SqlDriver (better-sqlite3), the plugin's own composition, run on two faces: native (plugin capabilities, NativeSQLStrategy) and objectql (narrowed to the engine aggregate). Per face: grouped by code gives { USD: 150, EUR: 100 } and currencyField: 'currency' with no currency; a totals grouping that keeps the code is served; three refusals (grouped by another dimension, no grouping, a grand total) answer INVALID_FIELD / 400 with field amount and the object, and the first two read nothing; a count is served ungrouped with neither key, and a count whose field is the amount gets neither key when grouped; the cube face refuses the same thing; the inferred cube (no registered dataset, amount_sum) refuses the ungrouped amount before anything is read and serves it grouped by currency_code. The draft preview path carries the pointer and refuses the ungrouped amount. Compile guards: a measure currency over the record amount is refused, and so is one on a derived sum of record amounts; a record amount derived with a fixed amount (all four ops) or with a tenant amount is refused, including one level up through a derived sum. The controls compile: a measure currency over a fixed field (contradicting or restating it), a derived sum of two different fixed codes, an amount per invoice, an amount with itself, and a fixed amount with the tenant one.

Existing pins unchanged

currency-mode-relay.test.ts (m_explicit_fixed: JPY over a fixed-EUR field still answers JPY) and query-dataset.test.ts are byte-identical to main at f79f10d. They were moved under the earlier narrowing ruling and have since been restored.

Package runs

At f79f10d, with main merged in, under the verify lock: pnpm install --frozen-lockfile, pnpm --filter '@objectstack/service-analytics...' build, then service-analytics typecheck && test, gave VERDICT command-exit 0, Test Files 196 passed (196), Tests 4734 passed / 282 skipped. The same counts as at 0d2291d. Spec: tsc --noEmit -p tsconfig.json 0 errors; vitest run src/api/analytics.test.ts 34/34; check:generated "All 14 generated artifacts are up to date", taken against the dist that build produced. Nothing needed regenerating after the merge.

Ablations (src-resolved; ablation-replace.mjs; every leg restored blob == HEAD, git diff HEAD empty)

All at 0d2291d (the suite itself 29/29 there). The merge of main changed none of this card's files:

  • M1, the inferred-cube door call removed → 2 failed / 29 (the inferred-cube case on both faces)
  • M2, the draft-preview door call removed → 1 failed / 29 (the preview refusal)
  • S1, the derived arm of guard 1 disabled → 1 failed / 29
  • S2, the amount-aggregate check on the pointer forced true → 2 failed / 29 (the grouped test on both faces, at priced_invoices)
  • door: grouping check replaced by continue → 11 failed / 29
  • per-row pointer assignment deleted → 5 failed / 29
  • guard 1, the record arm disabled → 1 failed / 29
  • guard 2, recordPairDiffers forced false → 6 failed / 29

Acceptance notes

  • Guards confined to the record mode (seat ruling, revised after the ADR contract review). The fixed-only arms (a measure currency contradicting a fixed field's code; a derived measure over two different fixed codes) are outside ruling [Decision] Currency, all of it: resume multi-currency (#8345) and in what shape, and one rule for an amount whose currency is not known (Studio's USD, the template's USD, fixed's CNY, the example's USD) #27119 and ADR-0144, so R1 has no authority to narrow there. They were dropped at 507ed8f, the m_explicit_fixed pin was restored, and the lines are back to yes (widening) / added. The changeset carries no BREAKING paragraph and no ADR-0087 marker. check-adr-0087-registration, check-changeset-no-major and check-empty-changeset all exit 0 at 0d2291d. ADR-0144 Open item 12 has since been ruled, letter A (6486427200 on [Epic] multi-currency (shape C′): record currency, dated exchange rates, write-time home-currency projection — 17 children, R1/R2/R3 release pairing (ruling 6465431966) #8345): both fixed-only shapes are refused on a card of their own after R1, and this PR stays record-mode only.
  • ADR-0144 citations re-checked against the landed text (3eaecb21). The fixed-only scope now cites open item 12 and its ruling, and a consolidated total cites D6 (it reads the home field D5 stores) where it had cited D5.
  • Contract review at ae8e3d4: FAIL (6490225184). Both must-fixes and two should-fixes are in 0d2291da: M1 (the inferred-cube exit is judged), M2 (the draft preview is judged), S1 (a currency on a record-resolved derived measure is refused) and S2 (no pointer on a count). Each has its ablation above.
  • Follow-on, not R1 (S3). A derived sum / difference of record-mode operands over one code field, grouped by it, carries neither key by contract, although its currency is computable from the same resolver. A renderer shows a plain number, which is not a mislabel.
  • Wire key spelling: settled by the contract review, keep currencyField. On the wire it names a result column, consistent with fields[].name.
  • The cube face (query) carries no per-row pointer, as it carries no currency today. Only the refusal applies there.
  • A relationship-path amount (account.amount) is refused by the cube door when ungrouped, because the door locates the column through the hop resolver. The column enrichment and the compile guards stand down on dotted fields, the same tier as the existing compile field-type door.
  • A where that pins one currency code is not counted as grouping. The rule is the grouping alone.
  • An explicit measure currency over a dynamic field is not judged (Q6's third guard). It is not in this card.

Verification

Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 0d2291d, against a fresh origin/main (968925c8), gave 127 commands. Each ran with its exit code captured before any pipe.

Reconciliation (--ran, exit codes recorded): 127 derived famil(ies) accounted for — 126 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3). All 126 measured gates exited 0.

NOT MEASURED, prerequisite refused locally (exit 3), CI-owned:

  • node scripts/check-plugin-teardown-shape.mjs --self-test: the shallow checkout cannot reach its pinned positive-control commit.

Pre-push checks, run by hand before each push: check-regen-pending --pre-push exit 0; check-commit-card-trailers --pre-push exit 0 (no card relation and no model identifier on any pushed commit).

At f79f10d, after the merge of main (no code change): check-adr-0087-registration, check-changeset-no-major and check-empty-changeset exit 0 against origin/main.

Full dogfood: n/a. The title has no ! and the Clause-② arm is (widening).

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran (true); pnpm lint is the CI-owned repo scan, not run locally.
  • 4. One issue, one branch, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

Generated by Claude Code

claude added 12 commits October 28, 2026 12:54
…unt points at a sibling ISO 4217 code field

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…ent in the dropped-refinements ledger

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…field naming convention, paid by tightening the currency block

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…cord-mode currencyField

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…ield on grouped results, cube door refuses an ungrouped amount aggregate, compile guards on measure currencies (WIP)

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
…, the preview path and the compile guards

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
…ly restate its code — the two chain pins move to the dynamic field

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
…ics minor, the two compile guards declared breaking with their ADR-0087 disposition

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
…protocol narrowed — the compile guards refuse dataset metadata that compiled before

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
…e field-level answer never reads a code from a record-mode config

Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Co-authored-by: Claude <noreply@anthropic.com>
…urrencyField row

The field form's new `currencyConfig.currencyField` row is one more
unwalked composite child (10 -> 11) and one more translated row label
in each locale's positive control (695 -> 696).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
… default

Under `currencyMode: 'record'` each record names its own code in the
sibling field, which only a per-row renderer can read. The field-level
answer is now `undefined` outright, so a record with a blank code shows
no symbol instead of a currency it never named.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
…only — the fixed-only arms are not R1's to narrow; the two chain pins restored, the changeset back to widening / added

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
os-bill pushed a commit that referenced this pull request Oct 29, 2026
…485013063

The review on 775d8c0 (PASS, no must-fix) found three places where the
record said less, or other, than its own Decision:

- Builds-on scoped ADR-0112 to two runtime refusals and called every
  build-time refusal an authoring diagnostic, while D6 places the
  ungrouped-aggregate refusal in two request-time doors. The paragraph
  now says ADR-0112 governs every refusal that answers on the wire, that
  "at build" covers the declared aggregates (summary roll-ups at
  registration, dataset measures at compile), and that an ad-hoc
  aggregate meets the same rule at the request door with the existing
  catalog code INVALID_FIELD; the two new codes stay the only ledger
  additions.
- Consequences predicted the consumers' Metadata-protocol declarations.
  It now states the record's own claim: record and homeAmount are
  additive, the one narrowing of what an author can write is #27157's,
  and each consumer's semver is its own changeset's to declare.
- Open item 12 records what the ruling on #27119 did not decide: whether
  cross-currency arithmetic between fixed fields of different codes is
  refused. The epic PM seat ruled that #27187 (PR #27338) confines its
  compile guards to record-mode amounts; the general guard waits on the
  maintainer and epic #8345 carries the question. Items 1-11 keep their
  numbers.

Claude-Session: https://claude.ai/code/session_01Utp6CbTZrFqRYNgjSaSbN5
Co-authored-by: Claude <noreply@anthropic.com>
os-bill pushed a commit that referenced this pull request Oct 29, 2026
…356516

The delta review on e9bb190 (PASS, no must-fix) found three residues in
the text the previous commit added:

- Builds-on called the declared-aggregate refusals "authoring diagnostics,
  not wire errors", which is wrong for the dataset-compile half (the
  compile runs in the request path and DATASET_INVALID / 400 reaches the
  caller) and loose for the registration half (INVALID_METADATA / 422 when
  a metadata write triggers it). The clause now names those existing
  codes; "They are the only ledger additions" stays the operative sentence.
- Consequences attributed #27157's narrowing to the epic; the record's own
  References line places #27157 outside the epic, as a member of R1. It now
  reads "R1's one narrowing of what an author can write".
- Open item 12 said the epic PM seat "ruled", a word this record reserves
  for the maintainer's text, and cited nothing a reader can open. It now
  says "decided", cites the decision comment on #27187 and PR #27338's
  commit 507ed8f as the tree evidence, and links the question as posted
  on epic #8345.

Claude-Session: https://claude.ai/code/session_01Utp6CbTZrFqRYNgjSaSbN5
Co-authored-by: Claude <noreply@anthropic.com>
The authorable-surface baseline is taken from main (#27233 retired
`CurrencyValue`) and rebuilt, keeping `currencyConfig.currencyField`;
the field reference page is regenerated from the merged tree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Utp6CbfVSc1hXtxLUPHZ2f
Brings in the record currency mode at its final head (main merged
through 73725c2). No conflicts.

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
…ts ruling, D6 for consolidation

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

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract-review-claim: session_01Utp6CbTZrFqRYNgjSaSbN5 · head ae8e3d4 · 2026-10-29T06:42Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: ae8e3d48ac205fba35a2e79cd6654e3ef14fe9e5
Local-runs: none
Scope: packages/spec/src/api/analytics.zod.ts, packages/spec/src/contracts/analytics-service.ts + packages/services/service-analytics/src/plugin.ts, packages/services/service-analytics/src/analytics-service.ts, packages/services/service-analytics/src/cube-record-currency-door.ts, packages/services/service-analytics/src/measure-currency-coherence.ts, packages/services/service-analytics/src/dataset-compiler.ts, packages/services/service-analytics/src/preview-evaluator.ts (base, read), packages/services/service-analytics/src/tests/record-currency-analytics.test.ts, .changeset/27187-record-currency-analytics.md, content/docs/api/data-api.mdx, packages/spec/src/ui/dataset.zod.ts (base, read), packages/spec/src/api/analytics.test.ts (the type pin, read); consumer evidence at PR #27340 head e128ccc8: ui/packages/app-shell/src/views/metadata-admin/previews/DatasetPreview.tsx, ui/packages/core/src/utils/dataset-format.ts, ui/packages/core/src/utils/chart-series.ts, and on main ui/packages/data-objectstack/src/index.ts (aggregate()). The rest of the diff is the lane seat's ACCEPT at TIER_DEFAULT. This card's own diff is b6f152bd...ae8e3d48 (10 files, +752/-8); #27268's 20 files were reviewed on #27268.

① Derived judgments

  1. AnalyticsResult.fields[].currencyField?: string (contract) and the same optional key on AnalyticsResultResponseSchema; currency absent beside it. Right. The compile-time pin AnalyticsResultMatchesContract (api/analytics.test.ts) holds the two shapes equal, so neither side moved alone; data-api.mdx lists the member.
  2. The pointer names a result column by its dimension name, not the object field: enrichResultColumns sets it from selectedDimensions(dataset, selection).find((d) => d.field === meta.currencyField)?.name, read off the dataset and the declaration, never off the rows; one seam for the live and preview paths; the __compare column gets it through the existing name-strip lookup. Right. The suite pins rows keyed by the dimension name on both strategies (r.currency on native and objectql).
  3. A record-mode column selected without its code dimension gets neither key. Right for the ungrouped count; see S2 for the grouped count.
  4. Cube door accept set: sum / avg / min / max over a record-mode amount is refused unless a dimensions entry resolves to the code field on the same object; count / count_distinct exempt (ADR-0144 open item 10); a where pin is not a grouping; INVALID_FIELD / 400 with member / param / cube plus field / object, as the analytics: a config cube min/max over a text column is served at the cube door under fields[] type number; the dataset door refuses the pair by the compatibility table, the cube door consults nothing #21044 door attaches them; stand-downs (no sourceFieldMeta, non-bare cube.sql, non-column sql) as that precedent. Right. totals groupings and the compareTo pass reach it one query at a time through DatasetExecutor, so the grand total is refused. Right. The cube face (query) is refused and carries no pointer. Right. The door runs on the augmented and the declared exits of ensureCube only: the inferred-cube exit is not judged (wrong, M1) and the draft-preview branch never reaches ensureCube (wrong, M2).
  5. Compile guard 1: a measure currency whose field is a record-mode base field is DATASET_INVALID / 400. Right for an aggregate measure; a derived carrier passes (S1).
  6. Compile guard 2: a derived measure, any op, whose monetary operands include a record-resolved one and one provably different (another code field, a fixed code, an explicit code, the tenant's), transitively through a derived sum / difference; a count is not compared; ratio / product resolve to no currency. Right. Counting record against the tenant's currency as a difference is right: a row's code cannot be assumed to be the tenant's.
  7. plugin.ts relays currencyField only under currencyMode: 'record', the rule that relays defaultCurrency only under fixed. Right; CurrencyConfigSchema already refuses the pointer outside record, so the relay cannot see one.
  8. compile() hands compileDataset a declaredCurrency hook off the same sourceFieldMeta the result chain reads, so the guards judge the currency the column would print. Right.
  9. data-api.mdx: the key listed among the optional members; the refusal stated "on the dataset query and the cube query alike; a count is served". Right as a statement of the contract; the tree delivers it on the declared-cube exits only (M1, M2).

② Semver level

  • @objectstack/spec minor: one optional member on a published interface and the mirrored optional key on a non-strict z.object response schema. Additive on the wire and in the type. Clause-②: yes (widening) is right. Nothing in this card's diff touched an authorable schema, so no generated artifact moved; check:generated is inside the green runs.
  • @objectstack/service-analytics minor: AnalyticsServiceConfig.sourceFieldMeta's return type gains an optional currencyField; DatasetCompileOptions.declaredCurrency is a new optional hook; the new refusals key on a currencyField the plugin relays only under record. Right.
  • Nothing compiling on main is refused: main's CurrencyConfigSchema.currencyMode is ['dynamic', 'fixed'], so no record-mode field exists for the door or either guard to key on. fixed / fixed shapes stay unjudged: guard 1 fires only on field.currencyField, guard 2 only through recordPairDiffers, and the suite's two CONTROL cases (eur_as_jpy, usd + eur) pin it. Confirmed.
  • Metadata-protocol: added: taken alone this diff adds no key a metadata author writes (the wire key is a response member) and refuses nothing on main, so its own kind would be none. added is the card's fixed line for the R1 pair, where the record mode and these constraints on it ship as one widening; it is the higher kind, no gate is under-declared by it, and the epic PM's 6485365183 confirms it. Right as fixed. No BREAKING paragraph and no ADR-0087 marker are owed on a widening; none is present. Right.

③ Boundary flags

  1. OQ1, declaration lines / guard scope — closed. 6485365183 confines both guards to record-mode amounts; ruling 6486427200 (letter A) puts both fixed-only shapes on a card of their own after R1. The tree at ae8e3d48 matches: measure-currency-coherence.ts judges the record mode alone, the controls compile, currency-mode-relay.test.ts and query-dataset.test.ts are byte-identical to the base.
  2. OQ2, wire key spelling — settled here: keep currencyField. Inside AnalyticsResult, fields[] is already the column vocabulary (fields[].name is a column), so currencyField reads as "the field of this answer that holds the currency", parallel to the authoring pointer ADR-0144 D2 names. The referent differs (authoring: an object field; wire: a result column's name) and both describe texts say so in their first sentence. One concept, one name across spec, wire and docs.
  3. Result-side consumer absent in R1 — escalated to the epic PM. feat(ui): record-mode currency — each record shows its own currency; ISO 4217 code select pre-filled with the organization currency; no cross-currency totals; Studio currency section #27340's diff reads currencyConfig.currencyField on record fields (resolver, Studio section, filter pairing, share-link projection) and nothing off AnalyticsResult.fields[]. The readers of fields[].currency at e128ccc8 are untouched: DatasetPreview.tsx:311 (formatMeasure(row[c], …, measureField(c)?.currency, …)), core/utils/dataset-format.ts, core/utils/chart-series.ts. A grouped record-mode answer therefore renders as plain numbers in dashboards and in the Studio dataset preview. No mislabel (the contract's plain-number rule), but the key ships with no reader; a card for row[name] formatted by row[currencyField] is owed, and Skills-docs: follow-up owed — skills dashboards guide still names files, not a card: the dispatch seat rewrites it to #N before ACCEPT.
  4. Q6 third guard (a measure currency over a dynamic field) — ruling 6486427200 keeps it legal; no card.
  5. Relationship-path amounts — the cube door judges them (account.amount ungrouped is refused, the code path spelled account.currency_code); enrichment and both compile guards stand down on a dotted field, so a grouped answer over a dotted amount carries no pointer and renders a plain number. The analytics: a config cube min/max over a text column is served at the cube door under fields[] type number; the dataset door refuses the pair by the compatibility table, the cube door consults nothing #21044 tier; accepted and noted.
  6. ADR-0144 open item 12 — the ruling asks for a dated one-sentence note; main's text still ends "stays undecided". The epic PM's, not this PR's.
  7. Check-runs at ae8e3d48 — every completed run is success or skipped; the Check Changeset run cancelled at 06:41Z is superseded by the 06:42Z success. Green.
  8. PR user.login is objectstack-fleet[bot]; no off-relay author to name.

④ Design judgment

packages/spec/src/contracts/analytics-service.ts. Against ADR-0144 D2 and D8 rule 2 the key holds: the per-row currency is relayed by reference to a column the answer already carries, nothing is injected, and the amount stays a bare number (ADR-0104 D1). Against D6 the hunk's own sentence — "A sum / avg / min / max over such an amount is refused unless the query groups by its code field" — does not hold on two paths of the tree it describes:

  • M1 (must-fix) — the inferred-cube exit of ensureCube is not judged. The if (!cube) branch (the ad-hoc query() / generateSql() path that mints a cube from the object name, cube.sql = name, measures from the ${field}_${fn} suffix) calls assertMeasureFieldTypes and never assertRecordCurrencyGrouped; the hunk added the call to the augmented and declared branches only. POST /analytics/query { cube: object, measures: ['amount_sum'], dimensions: [] } over a record-mode object is served: probe A's number with neither key. That is exactly the body ObjectStackDataSource.aggregate() sends (ui/packages/data-objectstack/src/index.ts: cube: resource, measures: [measureName], the ${field}_${function} convention), so the grid footers, group headers and metric tiles that feat(ui): record-mode currency — each record shows its own currency; ISO 4217 code select pre-filled with the organization currency; no cross-currency totals; Studio currency section #27340 did not suppress client-side, and every SDK or automation caller, reach the sum across currencies. The door's header claims "every path out of" ensureCube, D6 names this door as the refusal, and feat(ui): record-mode currency — each record shows its own currency; ISO 4217 code select pre-filled with the organization currency; no cross-currency totals; Studio currency section #27340's acceptance notes defer the tile's refusal to this card. Fix: the one call in that branch, plus a suite case with no registerDataset (an inferred cube on both faces).
  • M2 (must-fix) — the ADR-0037 P3 draft-preview branch is not judged. In answerDataset, previewDrafts with a pending seed builds previewService.query, which runs the stored-body, internal-field and readability gates and then evaluateAnalyticsQueryOverRows (preview-evaluator.ts, case 'sum': compensatedSum); ensureCube is never reached. An ungrouped sum / avg / min / max over a record-mode amount is served in a Studio draft preview with neither key, while the same selection is refused once published — the half-state D9 names, shown to the author while authoring. The hunk added the pointer to this path (enrichResultColumns) and the suite pins only the grouped preview. Fix: assertRecordCurrencyGrouped(q, compiled.cube) inside previewService.query (compiled.cube.sql is the bare object name), plus a refusal case under previewDrafts: true.

Should-fixes, to fold into the same delta:

  • S1 — guard 1 skips the derived carrier. DatasetMeasureSchema refuses currency only beside duration, so { derived: { op: 'sum', of: ['total', 'total'] }, currency: 'USD' } compiles (if (m.derived || …) continue), and enrichResultColumns labels the column USD (no m.field, so the else if branch takes mc.currency): one code over per-currency rows, the probe C defect one level up. Guard 2's resolvedOf already answers record for that measure; guard 1 should refuse a currency on any measure whose resolved currency is record. The contract scopes currencyField out of derived columns, so this is the guard's intent, not its text.
  • S2 — a count with field set to the record amount, grouped by the code, gets currencyField (the meta?.currencyField !== undefined branch has no aggregate check), so a count column points at a currency. Main's chain already labels a count over a fixed field with currency (monetary = meta?.type === 'currency'), so this is a carried class, but the new branch repeats it where a m.aggregate === 'count' guard would cost one line.
  • S3 — a derived sum / difference of record operands over one code field, grouped by it, carries neither key by contract ("absent on derived measures") although guard 2 resolves its currency to that code field; a renderer shows a plain number. Declared and not wrong; the pointer is computable from the same resolver. Follow-on, not R1.

Everything else in this file holds: the currency amendment ("Absent, too, on a column that carries currencyField") is enforced by the if / else if shape in enrichResultColumns, and no strategy writes fields[].currency of its own (none in strategies/ at the base), so the two keys cannot co-occur.

packages/spec/src/api/analytics.zod.ts. The mirror holds by the type pin; the key is optional on a non-strict object, so the wire widens and nothing bound to the old schema breaks; the describe texts agree with the producer ("Only on a queryDataset measure column"; currency absent beside it; a dimension column by name). Holds. Its "refused unless grouped" sentence inherits M1 and M2.

Implemented-by: claude/project-thread-oixqci
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: FAIL

…d cube and the draft preview; refuse a currency on a record-resolved derived measure; no pointer on a count

Contract review on ae8e3d4: the inferred-cube exit of ensureCube and the
draft-preview evaluator now refuse an ungrouped sum/avg/min/max over a
record-mode amount, as the declared paths do. Guard 1 also refuses a
measure currency on a derived measure whose resolved currency is the
record mode, and a count column no longer carries currencyField.

Claude-Session: https://claude.ai/code/session_01Utp6Ca8jzC2P7EAQvqjNBb
Co-authored-by: Claude <noreply@anthropic.com>
os-bill pushed a commit that referenced this pull request Oct 29, 2026
…ency (#27188)

A grouped analytics answer over a `record`-mode amount marks the measure
column with `currencyField` (#27338), naming the result column that holds
each row's ISO 4217 code. `measureCellCurrency(field, row)` reads it
structurally (the spec type does not carry it yet) and returns that row's
code, nothing for a row without one, and the column's declared `currency`
for every other column. The dashboard table's body cells and the Studio
dataset preview read it off the RAW row, so a relabelled dimension cannot
move a code. Totals and cross-tab cells pass no row and draw no symbol.

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

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0d2291da706c00ed7932b57b230c31ba7ae15775
Local-runs: none
Scope: packages/spec/src/api/analytics.zod.ts, packages/spec/src/contracts/analytics-service.ts + packages/services/service-analytics/src/analytics-service.ts, packages/services/service-analytics/src/cube-record-currency-door.ts, packages/services/service-analytics/src/measure-currency-coherence.ts, packages/services/service-analytics/src/plugin.ts (draft-rows resolver, read), packages/services/service-analytics/src/tests/record-currency-analytics.test.ts, .changeset/27187-record-currency-analytics.md. Delta review of ae8e3d48..0d2291da (one commit, 7 files, +104/-32) against record 6490225184 (FAIL at ae8e3d48); everything that record found right stands unchanged, and the rest of the diff remains the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

  1. M1 resolved. ensureCube now calls assertRecordCurrencyGrouped on all three exits: the inferred cube (line 3523, after assertMeasureFieldTypes), the augmented cube (3607) and the declared cube (3624); query() and generateSql() both enter through ensureCube, so there is no fourth exit. The suite's new case runs on both faces with no registerDataset (cube: OBJECT, measures: ['amount_sum'], the ${field}_${fn} body ObjectStackDataSource.aggregate() sends): INVALID_FIELD / 400, member amount_sum, field amount, the object, zero raw-SQL and zero engine-aggregate reads; grouped by currency_code it serves { USD: 150, EUR: 100 }. Right.
  2. M2 resolved. previewService.query calls this.assertRecordCurrencyGrouped(q, compiled.cube) after the three readability gates and before evaluateAnalyticsQueryOverRows (line 2731); compiled.cube.sql is the bare object name, so the door does not stand down. The plugin wires draftRowsResolver off the protocol service (plugin.ts 1187–1214, 1544) and the suite supplies pendingSeedProtocol, so the new case ({ measures: ['largest'] } under previewDrafts: true) runs the preview branch, not the live one, and is refused INVALID_FIELD / 400 with member / field / object. Right. The door header and the changeset now name all three exits and the preview gate.
  3. S1 resolved. Guard 1 now reads every measure with a currency (duration excepted): the aggregate arm as before, and a derived arm that refuses when the shared resolvedOf answers record (a sum / difference of record-mode operands over one code field), with its own prescription. resolvedOf / monetaryOperands are declared ahead of both guard loops, so the move introduces no use-before-definition; guard 2 is byte-for-byte the same judgment. Pinned by total_twice_usd. A derived ratio / product with a currency still compiles, as on main for every mode: dimensionless, not a per-row mislabel. Right.
  4. S2 resolved. The pointer is written only when AMOUNT_AGGREGATES.has(m.aggregate) (the door's set, now exported, so one list serves the refusal and the label); a count / count_distinct over the amount gets neither key, grouped or not (the record-mode branch is still taken, so no currency is written either). Pinned by priced_invoices on both faces. Contract and zod describe say the same ("never on a count"). Right.
  5. S3 stays a declared follow-on (body, Acceptance notes). Right.
  6. Nothing new is wrong: the delta touches no accept set beyond the four items; data-api.mdx is unchanged and its refusal sentence is now true on every path it names; the type pin is unaffected (describe and doc-comment text only).

② Semver level

Unchanged and right: @objectstack/spec minor / @objectstack/service-analytics minor, Clause-②: yes (widening), Metadata-protocol: added, no BREAKING paragraph, no ADR-0087 marker. The delta adds refusals on the record mode only (no record-mode field exists on main) and widens nothing further; fixed / fixed stays unjudged (the two CONTROL cases still compile). The changeset body was refreshed to match the tree (every path judged; a count carries neither key; the derived arm of guard 1).

③ Boundary flags

  1. Record 6490225184's M1, M2, S1, S2: closed in the tree, each with a pinning case (①.1–4). S3: carried as a follow-on, not R1.
  2. Skills-docs: follow-up owed — #27189 now names a card; the earlier file-naming flag is closed.
  3. Result-side consumer of currencyField (record 6490225184 ③.3): unchanged, still the epic PM's escalation; not this PR's.
  4. Check-runs at 0d2291da: 59 success, 9 skipped, none pending or failed (the 08:06Z Check Changeset completed success). Green.
  5. Body v5 describes the tree accurately on every point read here (29 cases; three exits plus the preview gate; the derived arm; the count rule).

④ Design judgment

packages/spec/src/contracts/analytics-service.ts — the D6 sentence that failed at ae8e3d48 ("refused unless the query groups by its code field") now holds on every path queryDataset and query take: the three ensureCube exits and the draft-preview gate are the only places a measure is evaluated, and all four judge. The amended count sentence matches enrichResultColumns. Holds. packages/spec/src/api/analytics.zod.ts — mirror unchanged, describe now consistent with the producer's aggregate rule. Holds. No must-fix, no new should-fix.

Implemented-by: claude/project-thread-oixqci
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract-review-claim: session_01Utp6CbTZrFqRYNgjSaSbN5 · head f79f10d · 2026-10-29T10:13Z (delta on 0d2291d: main merge after #27268 landed, same reviewer)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f79f10d048a84b380420bae207ea00d57bee828a
Local-runs: none
Scope: packages/spec/src/api/analytics.zod.ts, packages/spec/src/contracts/analytics-service.ts + the eight other files of the card's own diff (packages/services/service-analytics/src/analytics-service.ts, cube-record-currency-door.ts, measure-currency-coherence.ts, dataset-compiler.ts, plugin.ts, tests/record-currency-analytics.test.ts, .changeset/27187-record-currency-analytics.md, content/docs/api/data-api.mdx), read as the one patch; packages/spec/authorable-surface/data.json (the merge resolution, read); and main's movement 4145210308f5..f7188b56 (the merge-base), read for touches on these surfaces. Delta review on records 6490225184 (FAIL at ae8e3d48) and 6491468650 (PASS at 0d2291da): no code changed since the PASS, so its ①–④ stand and are not restated.

① Derived judgments

  1. The patch is unchanged. git diff b6f152bd 0d2291da and git diff f7188b56 f79f10d0 (f7188b56 = merge-base origin/main f79f10d0) have the same git patch-id --stable (ca63ff7cfd03f0444985fc8234482680a0be058d), 10 files, +824/-8, and are byte-identical apart from index lines. Right: the two new commits add nothing to the accept set.
  2. 48cdeb43 merges f7188b56 into the branch (parents 0d2291da, f7188b56). f79f10d0 changes one file against the merge result, packages/spec/authorable-surface/data.json (−35), and that file now equals origin/main's: the merge had kept the branch's copy of feat(spec): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1) #27268's regenerated artifact, main's squash carried the same surface regenerated, and the script took main's side. No other file differs between f79f10d0 and the merge result. Right.
  3. The diff against the base is this card's own. git diff --name-only f7188b56 f79f10d0 lists exactly the 10 files above; feat(spec): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1) #27268's 20 files are gone from the file list because feat(spec): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1) #27268 landed (4145210308f5, squashed from its head b6f152bd, the very content this card was built on, merged 09:39Z). Right.

② Semver level

Unchanged and right. Changeset at f79f10d0: @objectstack/spec minor, @objectstack/service-analytics minor, Clause-②: yes (widening), Metadata-protocol: added, no BREAKING paragraph, no ADR-0087 marker. Body v6 carries the same two lines plus Skills-docs: follow-up owed — #27189, and no longer the stacked-on-#27268 wording (Merge-after: is gone; the file list is the card's own). Now that the base holds currencyMode: 'record', the reading is the one the earlier records gave for the R1 pair: the key is an additive member on a published type and schema, the refusals key on a currencyField the plugin relays only under record, fixed / fixed stays unjudged, and nothing that compiles on main is refused. check-adr-0087-registration, check-changeset-no-major and check-empty-changeset are inside the green runs.

③ Boundary flags

  1. What the base gained between feat(spec): currencyMode: 'record' + currencyField — a record-mode amount points at a sibling ISO 4217 code field (multi-currency M0-1) #27268 and the merge-base: three PRs — feat(plugin-form,spec)!: ObjectForm lays the form out, the object-form block opens drawer/modal, split retires #27305 (feat(plugin-form,spec)!: ui/component.zod.ts, ui/view.zod.ts, ui/react-blocks.ts, conversions/registry.ts, four retired-key entries and one semantic migration, all form-layout), docs(skills): objectstack-data, -automation and -platform teach ownership queues and assignment rules (ADR-0142 phase 2) with one eval each #27426 and docs(skills): objectui guides stop teaching the retired @object-ui/plugin-ai and vscode-extension; the project-setup eval refuses the retired package #27470 (skills docs). None touches api/analytics.zod.ts, contracts/analytics-service.ts, ui/dataset.zod.ts, data/field.zod.ts, packages/services/service-analytics/** or data-api.mdx; no judgment of the earlier records moves.
  2. Main has moved past the merge-base (f7188b56..origin/main: fix(plugin-sharing)!: an unproven owner anchor on a federated object narrows the read and bulk-write scopes, never leaves them unscoped #27476 fix(plugin-sharing)!, pm-dispatch(triage): a round reads every main and queue red since the last record, answers pm:retriage on epics, and a lane holds a head with a Contract-review-hold: line that check-owed-reviews reads #27468 pm-dispatch). Neither touches these surfaces, and no file of the card's diff is among their files, so the queue's merge has nothing to resolve here. Not in the PR tree; noted for the landing seat.
  3. Record 6491468650's open items are unchanged: S3 stays a declared follow-on; the result-side consumer of currencyField remains the epic PM's escalation; Skills-docs names docs(data-modeling): the currency page — dynamic / fixed / record modes, the sibling code field, what refuses (multi-currency M0-5, epic #8345) #27189.
  4. Check-runs at f79f10d0: 54 runs, 43 success, 11 skipped, none pending or failed; Check Changeset success at 09:47Z and again at 10:13Z. Green.
  5. The author is the relay bot; the claim line 6493933694 names this head and this session.

④ Design judgment

packages/spec/src/contracts/analytics-service.ts and packages/spec/src/api/analytics.zod.ts: byte-identical to the heads already judged, and the code they describe is byte-identical too (①.1). Against ADR-0144 D2 / D6 and against the base they now sit on — CurrencyConfigSchema with currencyMode: 'record' and currencyField landed from b6f152bd — the judgments of 6491468650 hold: the three ensureCube exits and the preview gate judge every evaluated measure, the pointer is set only on sum / avg / min / max, guard 1 covers the derived carrier, fixed / fixed is unjudged. No must-fix, no new should-fix.

Implemented-by: claude/project-thread-oixqci
Reviewed-by: session_01Utp6CbTZrFqRYNgjSaSbN5

VERDICT: PASS

Head reviewed: f79f10d048a84b380420bae207ea00d57bee828a

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#8345#1 · os-bill · session_01Utp6CbTZrFqRYNgjSaSbN5

席位记录 · epic PM epic:#8345 · 2026-10-29T10:19Z

Head: f79f10d048a84b380420bae207ea00d57bee828a · Session session_01Utp6CbTZrFqRYNgjSaSbN5

维护者速读

席位意见:可以合。契约复审先 FAIL(6490225184,两处漏拦截)后修复,在 0d2291da 上 PASS(6491468650);#27268 合入后并入 main,自身 diff 的 patch-id 与 0d2291da 相同,复审人在本 head 上增量 PASS。

席位核对

ACCEPT · epic PM epic:#8345 · head f79f10d0

ACCEPT → this comment.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 29, 2026 10:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 29, 2026
Merged via the queue into main with commit 3604371 Oct 29, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-oixqci branch October 29, 2026 10:40
os-bill pushed a commit that referenced this pull request Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:data size/l tests tooling

Projects

None yet

2 participants