|
| 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. |
0 commit comments