Skip to content

Commit 2a41d12

Browse files
authored
docs: Multi-asset purity ADR, integration tests, and service README updates (#1499)
* docs: Add multi-asset purity ADR, integration tests, and README updates - Add ADR-0035 documenting the multi-asset purity enforcement architecture: API-boundary validation via Reference Data with trust-the-caller domains - Add integration tests verifying multi-asset support across all dimensions (CURRENCY, ENERGY, CARBON, COMPUTE, DATA, COUNT) with arithmetic and precision handling - Update service READMEs with instrument resolution patterns for current-account, position-keeping, financial-accounting, and internal-account * fix: Address review feedback on multi-asset documentation and tests - Move tests from tests/integration/ to shared/pkg/amount/ (they test domain constructors, not service boundaries) - Add cross-dimension Compare assertion for complete coverage - ADR: Document NewFromInstrument backward-compatible CURRENCY path - READMEs: Scope instrument resolution claims to API boundary and account creation, clarify persistence reconstruction path --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent f19f01b commit 2a41d12

6 files changed

Lines changed: 363 additions & 6 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
---
2+
name: adr-0035-multi-asset-purity
3+
description: Enforce Reference Data as the sole source of instrument properties across all services
4+
triggers:
5+
- Adding a new instrument type or asset class to the platform
6+
- Creating or modifying account/position constructors that accept instrument codes
7+
- Reviewing CI failures from the multi-asset purity lint check
8+
- Migrating a service from hardcoded currency logic to Reference Data resolution
9+
instructions: |
10+
All instrument properties (code, dimension, precision) must be resolved from Reference Data
11+
at the API boundary (gRPC layer). Domain constructors trust caller-provided instrument
12+
properties and must not consult local currency registries. Use quantity.NewInstrument() and
13+
amount.Zero()/amount.New() for domain-level amount construction. The CI lint rule
14+
(scripts/lint-multi-asset-purity.sh) enforces this at merge time.
15+
---
16+
17+
# 35. Multi-Asset Purity Enforcement
18+
19+
Date: 2026-03-07
20+
21+
## Status
22+
23+
Accepted
24+
25+
## Context
26+
27+
Meridian's architecture supports multiple asset dimensions (CURRENCY, ENERGY, CARBON, COMPUTE,
28+
DATA, COUNT, etc.) through the `quantity.Instrument` type system. However, early service
29+
implementations hardcoded assumptions about currency-only instruments:
30+
31+
- Domain constructors called `currency.ByCode()` to validate instrument codes against a local
32+
ISO 4217 registry, rejecting non-currency instruments like KWH or GPU_HOUR
33+
- Precision was hardcoded to 2 decimal places (appropriate for GBP/USD but not for energy
34+
meters at 6dp or carbon credits at 0dp)
35+
- Switch statements on instrument codes created implicit registries that required code changes
36+
to support new asset classes
37+
38+
These hardcoded paths prevented the platform from fulfilling its multi-asset mission. A
39+
systematic migration was needed to centralize instrument knowledge in Reference Data and
40+
make all services instrument-agnostic.
41+
42+
## Decision Drivers
43+
44+
* **Multi-asset support**: Services must handle any valid instrument without code changes
45+
* **Single source of truth**: Reference Data service owns instrument definitions (code, dimension, precision)
46+
* **Fail-closed safety**: Missing Reference Data must prevent account creation, not fall back to defaults
47+
* **Backward compatibility**: Existing CURRENCY accounts must continue working during migration
48+
* **CI enforcement**: New violations must be caught before merge, not discovered in production
49+
50+
## Considered Options
51+
52+
1. **Runtime validation via middleware** - Inject instrument validation at the gRPC interceptor level
53+
2. **Domain-level validation with extensible registry** - Keep validation in domain, make registry pluggable
54+
3. **API-boundary validation with trust-the-caller domains** - Validate at gRPC layer, domain trusts caller
55+
56+
## Decision Outcome
57+
58+
Chosen option: "API-boundary validation with trust-the-caller domains", because it
59+
cleanly separates concerns (validation at boundaries, pure logic in domains) and
60+
eliminates the need for domain packages to know about external registries.
61+
62+
### Architecture
63+
64+
```
65+
gRPC Layer (API Boundary)
66+
|
67+
+-- instrumentGetter.GetInstrument(ctx, code, version)
68+
| Returns: dimension, precision, metadata
69+
| Fails: codes.InvalidArgument (unknown), codes.FailedPrecondition (unavailable)
70+
|
71+
v
72+
Domain Layer (Pure Logic)
73+
|
74+
+-- quantity.NewInstrument(code, version, dimension, precision)
75+
+-- amount.Zero(inst) / amount.New(inst, minorUnits)
76+
| No external lookups. No registry calls. Trusts caller.
77+
|
78+
v
79+
Persistence Layer
80+
|
81+
+-- Stores instrument_code (VARCHAR 32) + dimension + precision
82+
+-- Reconstructs Amount via quantity.NewInstrument on read
83+
```
84+
85+
### Migrated Services
86+
87+
| Service | Migration | Key Change |
88+
|---------|-----------|------------|
89+
| current-account | Task 6 | Removed `currency.ByCode()` from domain, fail-closed gRPC |
90+
| position-keeping | Task 7 | `InstrumentResolver` replaces hardcoded precision lookup |
91+
| internal-account | Task 8 | Widened instrument columns, Reference Data resolution |
92+
| financial-accounting | Task 9 | `InstrumentResolver` for journal entry validation |
93+
| payment-order | Task 10 | Documented as intentionally currency-only (business constraint) |
94+
95+
### Positive Consequences
96+
97+
* New asset classes (e.g., water rights, bandwidth credits) work without code changes
98+
* Instrument precision is always correct (resolved from Reference Data, not assumed)
99+
* CI lint prevents regression to hardcoded patterns
100+
* Domain code is simpler (no external dependencies for instrument validation)
101+
102+
### Negative Consequences
103+
104+
* Account creation requires Reference Data availability (fail-closed)
105+
* Persistence layer reconstruction uses `quantity.NewInstrument` directly (bypasses registry)
106+
* `amount.NewFromInstrument` retains a CURRENCY-specific path that consults the legacy currency
107+
registry for backward compatibility during persistence reconstruction. Non-CURRENCY dimensions
108+
use caller-provided precision directly. This compatibility path will be removed when the
109+
legacy currency packages are deleted.
110+
* Legacy `shared/domain/money` and `shared/platform/quantity/currency` packages are deprecated
111+
but not yet removed (backward compatibility for tests and seed data)
112+
113+
## CI Enforcement
114+
115+
The multi-asset purity lint (`scripts/lint-multi-asset-purity.sh`) runs on every PR and checks for:
116+
117+
1. **Hardcoded instrument codes** in string comparisons (`== "GBP"`)
118+
2. **Switch statements** on instrument codes (`case "KWH"`)
119+
3. **Deprecated imports** (`shared/domain/money`)
120+
4. **Hardcoded precision** (`defaultPrecision = 2`)
121+
5. **Legacy registry calls** (`currency.ByCode()`)
122+
123+
Allowlisted paths: test files, seed commands, the currency package itself, and
124+
payment-order (intentionally currency-only). Known violations are tracked in
125+
`is_known_violation()` and must be documented.
126+
127+
## Links
128+
129+
* [Multi-Asset Purity Lint Script](../../scripts/lint-multi-asset-purity.sh)
130+
* [Shared Amount Package](../../shared/pkg/amount/)
131+
* [Quantity Instrument Type](../../shared/platform/quantity/instrument.go)
132+
* [Reference Data Instrument Registry](../../services/reference-data/registry/)
133+
134+
## Notes
135+
136+
The deprecated `shared/domain/money` and `shared/platform/quantity/currency` packages should
137+
be removed once all test fixtures and seed data are migrated to use `shared/pkg/amount`
138+
directly. The `--strict` mode of the lint script will fail on known violations and can be
139+
used to track progress toward full removal.

services/current-account/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,32 @@ All webhook delivery attempts are recorded in the `webhook_deliveries` table for
621621
- `Content-Type: application/json` header is set
622622
- Tenants should validate the `tenant_id` matches their expected value
623623

624+
## Instrument Resolution (Account Creation)
625+
626+
Account creation resolves instrument properties (dimension, precision) from Reference Data
627+
at the gRPC layer. The domain trusts caller-provided instrument properties. Downstream
628+
operations (deposits, withdrawals, balance queries) use the instrument properties stored
629+
at account creation time.
630+
631+
**Resolution flow:**
632+
633+
1. `InitiateCurrentAccount` receives `instrument_code` (e.g., `"GBP"`, `"KWH"`)
634+
2. gRPC layer calls `instrumentGetter.GetInstrument(ctx, code, version)` to resolve dimension and precision
635+
3. Domain constructor `NewCurrentAccountWithDimension()` uses `quantity.NewInstrument()` directly (no local registry lookup)
636+
4. If Reference Data is unavailable, account creation fails closed (`codes.FailedPrecondition`)
637+
638+
**Error handling for instrument lookup:**
639+
640+
| Error | gRPC Code | Behavior |
641+
|-------|-----------|----------|
642+
| Instrument not found | `InvalidArgument` | Unknown instrument code |
643+
| Context canceled | `Canceled` | Client canceled the request |
644+
| Context deadline exceeded | `DeadlineExceeded` | Lookup timed out |
645+
| Reference Data unavailable | `FailedPrecondition` | Service not configured |
646+
| Other errors | `Unavailable` | Transient failure, client should retry |
647+
648+
See [ADR-0035: Multi-Asset Purity](../../docs/adr/0035-multi-asset-purity.md) for the architectural decision.
649+
624650
## References
625651

626652
- [BIAN Current Account Specification](https://github.com/bian-official/public/blob/main/release14.0.0/semantic-apis/oas3%20/yamls/CurrentAccount.yaml)

services/financial-accounting/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,17 @@ erDiagram
210210
| `financial_accounting_double_entry_validations_total` | Counter | Balance checks |
211211
| `financial_accounting_errors_total` | Counter | Errors by category |
212212

213+
## Instrument Resolution
214+
215+
Ledger posting validation uses `InstrumentResolver` from Reference Data to verify instrument
216+
properties at the API boundary. The domain layer trusts caller-provided instrument properties.
217+
218+
The service validates that debit and credit entries within a booking log use matching
219+
instruments (cross-instrument postings are rejected). The database schema stores
220+
`instrument_code` as `VARCHAR(32)` to accommodate non-currency instrument codes.
221+
222+
See [ADR-0035: Multi-Asset Purity](../../docs/adr/0035-multi-asset-purity.md) for the architectural decision.
223+
213224
## References
214225

215226
- [BIAN Financial Accounting Specification](https://github.com/bian-official/public/blob/main/release14.0.0/semantic-apis/oas3%20/yamls/FinancialAccounting.yaml)

services/internal-account/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,8 @@ req := &iba.InitiateInternalAccountRequest{
434434
}
435435
```
436436

437+
See [ADR-0035: Multi-Asset Purity](../../docs/adr/0035-multi-asset-purity.md) for the architectural decision.
438+
437439
## Balance Delegation to Position Keeping
438440

439441
**Critical Design Decision**: Internal Account does NOT store balance locally.

services/position-keeping/README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -281,14 +281,20 @@ erDiagram
281281
| Max Transaction Entries | 10,000 | `ErrTooManyEntries` |
282282
| Max Audit Entries | 10,000 | `ErrTooManyEntries` |
283283

284-
## Currency Support
284+
## Instrument Resolution
285285

286-
7 currencies with proper decimal handling:
286+
Position-keeping supports all instrument dimensions (CURRENCY, ENERGY, CARBON, COMPUTE, etc.).
287+
Instrument properties are resolved via `InstrumentResolver` from Reference Data.
287288

288-
| Currency | Decimals |
289-
|----------|----------|
290-
| GBP, USD, EUR, CHF, CAD, AUD | 2 |
291-
| JPY | 0 |
289+
**Resolution flow:**
290+
291+
1. Transaction recording receives `instrument_code` and resolves properties via `InstrumentResolver`
292+
2. Balance computation uses the resolved precision for decimal arithmetic
293+
3. Persistence reconstruction uses stored `instrument_code`, `dimension`, and `precision` columns
294+
with `quantity.NewInstrument()` directly (no Reference Data call on read path)
295+
296+
All instrument dimensions and precision values are defined in Reference Data. See
297+
[ADR-0035: Multi-Asset Purity](../../docs/adr/0035-multi-asset-purity.md) for the architectural decision.
292298

293299
## Configuration
294300

0 commit comments

Comments
 (0)