# ADR Conformance Notes — Multi-Wallet

Date: 2026-03-30
Status: approved
Phase: 2 (Domain Contract and ADR Alignment)
ADRs Reviewed: 0001, 0002, 0003, 0004, 0005

## Summary

All 5 reviewed ADRs are **conformant** with the multi-wallet design. No violations or exceptions required. Notes below document how each ADR constraint is satisfied.

---

## ADR 0001 — App Boundaries and Dependency Rules

**Conformance: PASS**

| ADR Constraint | Multi-Wallet Design |
|---|---|
| wallet_accounts owns wallet account lifecycle SoR | WalletProduct, SubWallet, CurrencyConfig all live in `wallet_accounts`. Sub-wallet transfers are orchestrated by `wallet_transfers` — both are within domain ownership. |
| wallet_ledger owns financial posting SoR | All balance updates go through `wallet_ledger.LedgerStore` and the posting pipeline. No direct balance mutation in `wallet_accounts`. |
| wallet_transfers owns transfer lifecycle SoR | `TransferBetweenSubWallets` command lives in `wallet_transfers`. |
| No cross-app direct table reads | All cross-app access uses DI-injected functions (`balance_checker_fn`, `ledger_poster_fn`, `sub_wallet_resolver_fn`). Zero compile-time coupling between domain apps. |
| wallet_web must not implement financial domain logic | wallet_web API controllers delegate all business logic to command modules. Backward compat resolver lives in `wallet_accounts` — wallet_web only calls it. |
| Circular dependencies forbidden | Dependency flow: wallet_web → wallet_accounts → wallet_database; wallet_transfers → wallet_accounts (via DI only). No cycles. |

**New apps NOT required.** All new domain objects fit within existing app boundaries.

**New dependency to validate in CI boundary check:**
- `wallet_transfers` → `wallet_accounts` (sub-wallet resolution): Must use DI function, not direct module call. `scripts/check_boundaries.sh` will remain at 0 violations.

---

## ADR 0002 — Domain Eventing with Outbox/Inbox Reliability

**Conformance: PASS**

| ADR Constraint | Multi-Wallet Design |
|---|---|
| Events must have required metadata (event_id, event_name, version, occurred_at, correlation_id, aggregate_id, payload) | All 13 new events include these fields — see command-query-event-contracts.md § 4. |
| Past tense semantic names with version suffix | WalletProductCreated.v1, SubWalletFrozen.v1, etc. All conform. |
| Non-breaking additions allowed within version | v1 payloads include all fields; future additions are backward-compatible. |
| Breaking changes require new version | No breaking changes to existing events. New events are solely additive. |
| Outbox/inbox reliability for cross-app paths | PubSub broadcast pattern used for intra-umbrella events (same reliability as existing accounts/transfers events). Outbox persistence pattern applies when events cross process boundaries. |
| Events must not contain PII/secrets | Event payloads contain IDs and amounts only — no plaintext names, cards, or credentials. |

**Event schema consistency note:** existing events use `payload` key; this is the established pattern and all 13 new events follow it. (Event key inconsistency bug noted in Phase 1 gap analysis is a separate P1 cleanup item, not a blocker.)

---

## ADR 0003 — Financial Ledger Invariants and Posting Rulebook

**Conformance: PASS**

| ADR Invariant | Multi-Wallet Handling |
|---|---|
| Balance invariant (debits = credits per journal) | Sub-wallet transfers produce two journal entries: debit source + credit target. Sum(debits) == Sum(credits) for every transfer journal. |
| Immutability invariant (no update/delete of entries) | BalanceLedger rows are updated via transactions only. Ledger entries are append-only. |
| Idempotency invariant (same reference_id → same outcome) | `TransferBetweenSubWallets` requires idempotency_key; duplicate calls return prior result. `reference_id` is derived from idempotency_key for posting dedup. |
| Currency invariant (no mixed-currency journals) | Wave 1 transfers are single-currency only. All sub-wallets inherit wallet product primary_currency. FX journals are Wave 2+. |
| Freeze invariant (debit posting rejected on frozen account) | Posting pipeline checks sub-wallet status before accepting debit. `FreeZeSubWallet` / cascade freeze blocks all money movement. |
| Authorization-posting invariant | Balance check (source >= amount) precedes posting. Atomic: check + post in same ETS transaction sequence. |
| Precision invariant (no floating-point) | Amounts are integer minor units (kobo/cents) throughout — in structs, store, events, and API. |
| No cross-app direct writes to ledger tables | Only `wallet_ledger` writes to ETS ledger store. `wallet_accounts` and `wallet_transfers` access via `ledger_poster_fn` DI function. |

**New ledger entry fields required:**
- `sub_wallet_id` (nullable) added to Entry struct and ledger_entries table (Phase 3 migration)

This is a non-breaking extension. Existing entries without `sub_wallet_id` remain valid and are attributed to the default sub-wallet via the backward compat resolver.

---

## ADR 0004 — Idempotency and Locking Strategy

**Conformance: PASS**

| ADR Constraint | Multi-Wallet Handling |
|---|---|
| API-level idempotency key required for all money movement writes | `TransferBetweenSubWallets` requires `:idempotency_key` in opts. Missing key → domain error :idempotency_key_required. |
| Domain-level business reference uniqueness | `reference_id` on SubWalletTransfer and linked ledger entries is unique. Enforced by DB unique constraint on `transactions.idempotency_key`. |
| wallet_state persists idempotency records | Same pattern as existing transfer commands. `wallet_state` app is unchanged. |
| Lock for critical transfer transitions | Sub-wallet balance check + ledger post are in same ETS GenServer call sequence. ETS GenServer serializes concurrent requests per-process. DB-level: `SELECT ... FOR UPDATE` on balance row during write-through. |
| Idempotency key scope: (tenant_id, actor_id, route_signature) | Implemented as with existing commands. |
| Replay returns prior semantic result | Duplicate `TransferBetweenSubWallets` with same idempotency_key → returns {:ok, prior_transfer, []} (no new events). |
| Keys expire after retention window | Transfer writes: 72 hours (per ADR 0004 default). |

**Non-money-movement commands** (freeze, create, close) have optional idempotency keys per contract. This follows the same pattern as existing commands (e.g., FreezeWalletAccount takes optional idempotency_key in opts) and is consistent with ADR 0004's "required for money movement" scope.

---

## ADR 0005 — API Error Envelope and Idempotency Response Contract

**Conformance: PASS**

| ADR Constraint | Multi-Wallet Handling |
|---|---|
| Canonical error envelope | All new REST endpoints in wallet_web use existing ErrorEnvelope.build/2 from wallet_api_contracts. |
| idempotency_replayed field in success envelope | TransferBetweenSubWallets API response includes `idempotency_replayed: true` on duplicate. |
| Standard HTTP status codes | 201 Created for wallet product/sub-wallet creation; 200 for updates/actions; 409 for conflicts; 400 for validation. |
| Error code registry additions (new codes) | Three new error codes added below. |
| Backward-incompatible changes require API version bump | All new endpoints are at `/api/v1/` prefix. Existing endpoints are unchanged. No version bump needed. |

**New error codes for registry (to be added to ADR 0005 registry):**

| Code | Category | Retryable | Meaning |
|---|---|---|---|
| WALLET_PRODUCT_FROZEN | business | false | Requested operation blocked because wallet product is frozen |
| SUB_WALLET_FROZEN | business | false | Requested operation blocked because sub-wallet is frozen |
| FROZEN_BY_PARENT | business | false | Cannot directly unfreeze sub-wallet that is frozen by parent product |
| OPEN_SUB_WALLETS_REMAIN | business | false | Wallet product close rejected: one or more sub-wallets are still open |
| CANNOT_CLOSE_DEFAULT_SUB_WALLET | business | false | The default/general sub-wallet cannot be independently closed |
| INCOMPATIBLE_CURRENCY | business | false | Target sub-wallet does not support source currency |

---

## App Boundary Impact Summary

| App | Change Type | Description |
|---|---|---|
| wallet_accounts | Extend | New structs (WalletProduct, SubWallet, CurrencyConfig), new stores, new commands, new events |
| wallet_ledger | Extend | New query functions (get_sub_wallet_balance, get_aggregate_product_balance), new sub_wallet_id field on Entry |
| wallet_transfers | Extend | New TransferBetweenSubWallets command, new SubWalletTransfer struct |
| wallet_database | Extend | 3 new tables, 4 table modifications, new Ecto schemas, new persistence modules |
| wallet_web | Extend | New REST endpoints, new API controllers/LiveViews, backward compat resolver calls |
| wallet_events | No change | DomainEvent behaviour reused as-is |
| wallet_state | No change | Idempotency state contract reused as-is |
| wallet_observability | No change | AuditEvent.build/N pattern reused as-is |
| wallet_shared_kernel | No change | TypedId.generate/1 used for new ID prefixes ("wp", "sw", "cc", "swt") |

---

## Conformance Sign-Off

| ADR | Conformant | Notes |
|---|---|---|
| 0001 App Boundaries | Yes | DI pattern preserves all boundaries |
| 0002 Eventing | Yes | 13 new events, all ADR-compliant |
| 0003 Ledger Invariants | Yes | All 8 invariants satisfied |
| 0004 Idempotency | Yes | Money movement requires idempotency key |
| 0005 Error Envelope | Yes | 5 new error codes added to registry |
