MOD-100 — External asset connector¶
Purpose¶
Connects to Akahu (NZ open-banking aggregator) under explicit OAuth 2.0
customer consent, polls for KiwiSaver / superannuation / held-away bank
balances daily off-peak, normalises provider-specific shapes into a
canonical asset payload, and publishes
bank-platform.external_asset_updated events. Cached external asset
records and consent tokens are owned by this module (see "Architecture
note" below); the eventual bank-core SD01 assets consumer subscribes
to the EB event when that module is built.
FR scope: FR-401 (daily Akahu poll, retry, alert), FR-402 (normalisation + EB publish), FR-403 (consent token + revoke + 24h purge), FR-404 (provider-mapping config-driven).
Architecture¶
Akahu API (real or MOD-157 stub via SSM `/bank/{env}/providers/akahu/url`)
▲
│ OAuth 2.0 user-token + app credentials
│
┌──────────┴──────────────────────────────────┐
│ akahu-poller Lambda (cron 14:00 UTC daily) │
│ 1. ensureSchema (DDL) │
│ 2. SELECT consents WHERE revoked_at IS NULL│
│ 3. for each consent: │
│ a. listAccounts (3-attempt exp backoff)│
│ b. normalise (provider registry) │
│ c. upsert assets_staged │
│ d. publish external_asset_updated │
│ e. on terminal failure: bump fails; │
│ at >=3 fire retrieval_failed │
└──────────┬──────────────────────────────────┘
▼
Neon external_assets.assets_staged ──▶ bank-platform EB bus
external_assets.akahu_consent ──▶ external_asset_updated
external_assets.consent_audit_log ──▶ bank-app EB bus
external_assets.retrieval_failures ──▶ external_asset_retrieval_failed
┌─────────────────────────────────────┐
│ consent-revoke-cleaner Lambda │
│ (rate(1 hour)) │
│ PRI-003 GATE: deletes assets_staged│
│ for revoked consents within 24h │
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ consent-api Lambda │
│ (direct invoke; behind MOD-075 │
│ route once app integration ships) │
│ register / revoke / refresh │
│ writes consent_audit_log │
└─────────────────────────────────────┘
Architecture note — schema ownership¶
Per the post-MOD-158 architectural rule, this module owns its own
external_assets schema in the bank_platform Neon database. The
SD01 assets table — referenced verbatim in the wiki's FR-402 text —
is owned by bank-core; that module does not yet exist. When it ships,
it will subscribe to bank-platform.external_asset_updated and apply
events into its own table. Same pattern as MOD-079 (Snowflake → Neon
inbox + own delivery_log) vs the SD01 accounts write-back consumer.
This is the deliberate "modules own their schemas; cross-domain writes go via inbox + event" rule established when MOD-158 was pivoted away from owning DDL for downstream domain tables. FR-402 wording in the wiki ("writing to the assets and asset_party_relationships tables") predates the rule and needs a wiki amendment.
What MOD-100 owns¶
| Resource | Purpose |
|---|---|
external_assets.akahu_consent |
Per-customer Akahu OAuth consent (token + scope + expiry + revocation) |
external_assets.assets_staged |
Normalised cached asset rows. PK (customer_id, akahu_account_id). Deleted within 24h of consent revocation (PRI-003 GATE). |
external_assets.consent_audit_log |
Append-only audit of consent lifecycle. UPDATE/DELETE revoked from app_user (PRI-001 LOG). |
external_assets.retrieval_failures |
Per-customer rolling failure counter. Alerts at >=3 consecutive (FR-401). |
akahu-poller Lambda |
Daily 14:00 UTC (02:00 NZST). Off-peak per FR-401. |
consent-revoke-cleaner Lambda |
Hourly. Enforces PRI-003 24h SLA via 1h look-ahead. |
consent-api Lambda |
Direct-invoke handler for register/revoke/refresh. |
| 2× EventBridge schedules (daily poller, hourly cleaner) | |
| SQS DLQ (KMS, 14d) | Lambda panic safety net |
| 4× CloudWatch alarms | poller errors, cleaner errors, consent-api errors, DLQ depth |
| 13× SSM downstream contract paths |
Canonical asset shape (src/shared/asset.ts)¶
| Field | Type | Required | Notes |
|---|---|---|---|
asset_id |
uuid | ✓ | Deterministic v5: uuidv5("mod100:{customer_id}:{akahu_account_id}") |
customer_id |
uuid | ✓ | |
akahu_account_id |
string | ✓ | Provider account id (Akahu's _id) |
asset_type |
enum KIWISAVER|SUPERANNUATION|EXTERNAL_DEPOSIT | ✓ | |
provider_name |
string | ✓ | Canonical name from registry, falls back to Akahu's |
fund_name |
string | optional | |
fund_type |
enum CONSERVATIVE|BALANCED|GROWTH|AGGRESSIVE|DEFAULT | optional | |
member_number |
string | optional | |
balance_minor |
integer | ✓ | Cents — avoids float drift |
currency |
enum NZD|AUD | ✓ | |
last_contribution_date |
ISO date | optional | |
as_at |
ISO timestamp | ✓ | Stamped at fetch time |
raw |
jsonb | ✓ | Verbatim provider payload for audit |
Provider registry (FR-404)¶
src/config/provider-registry.ts ships V1 entries for the major NZ
KiwiSaver schemes known to be present on Akahu (ANZ, ASB, Booster,
Generate, Milford, Simplicity, Westpac). Adding a new provider is a
single object literal — no code changes elsewhere:
{
canonicalName: "Fisher Funds",
aliases: ["fisher"],
fundTypeKeywords: {
CONSERVATIVE: ["conservative"],
BALANCED: ["balanced"],
GROWTH: ["growth"],
},
}
Direct AU super fund APIs (Phase 2) become entries with
treatInvestmentAs: "SUPERANNUATION" once their direct clients land.
SSM contract¶
Read¶
| Path | Owner |
|---|---|
/bank/{env}/network/vpc-id, /private-subnet-ids |
MOD-104 |
/bank/{env}/kms/operational/arn |
MOD-104 |
/bank/{env}/sns/alerts/arn |
MOD-104 |
/bank/{env}/eventbridge/bank-platform/arn, /bank-app/arn |
MOD-104 |
/bank/{env}/neon/*, bank-neon/{env}/bank_platform/* |
MOD-103 |
/bank/{env}/providers/akahu/url |
MOD-157 stub (dev/uat); operator-set in prod |
bank-platform/{env}/akahu/app (Secrets Manager) |
Akahu app credentials — app_id, app_secret |
Write¶
| Path | Value |
|---|---|
/bank/{env}/mod100/poller-lambda-arn, /poller-lambda-name |
Daily poller pointers |
/bank/{env}/mod100/cleaner-lambda-arn, /cleaner-lambda-name |
Hourly cleaner pointers |
/bank/{env}/mod100/consent-api-lambda-arn, /consent-api-lambda-name |
Consent-API pointers |
/bank/{env}/mod100/staging-table |
external_assets.assets_staged |
/bank/{env}/mod100/consent-table |
external_assets.akahu_consent |
/bank/{env}/mod100/audit-log-table |
external_assets.consent_audit_log |
/bank/{env}/mod100/dlq-arn, /dlq-url |
DLQ for ops drain |
/bank/{env}/mod100/poller-schedule-name, /cleaner-schedule-name |
EB schedule names |
EventBridge contract¶
Publish¶
bank-platform.external_asset_updated (one per asset row updated):
{
"customer_id": "uuid",
"asset_id": "uuid",
"asset_type": "KIWISAVER|SUPERANNUATION|EXTERNAL_DEPOSIT",
"provider_name": "Booster",
"fund_name": "Booster Conservative Fund",
"fund_type": "CONSERVATIVE",
"balance_minor": 1234567,
"currency": "NZD",
"last_contribution_date": "2026-04-15",
"as_at": "2026-04-28T02:03:14Z"
}
bank-app.external_asset_retrieval_failed (per customer at >=3
consecutive failures):
{
"customer_id": "uuid",
"consecutive_failures": 3,
"last_error": "Akahu GET /accounts failed (503)",
"last_failure_at": "2026-04-28T02:00:14Z"
}
Consume¶
None V1.
FR / Policy coverage¶
| FR / Policy | How |
|---|---|
| FR-401 (daily Akahu poll, retry, alert) | EB schedule cron(0 14 * * ? *) (02:00 NZST). Per-customer 3-attempt exponential backoff via withRetry in src/shared/retry.ts. retrieval_failures.consecutive_failures counter; external_asset_retrieval_failed fired (once) at >=3 |
| FR-402 (normalise + EB publish) | src/shared/normalise.ts maps Akahu account+balance → CanonicalAsset; validator gate; publishAssetUpdated() fires the event after upsert. Deterministic asset_id makes re-publishes idempotent |
| FR-403 (consent + 24h purge) | consent-api Lambda registers/revokes/refreshes; revoke writes revoked_at and cache_purge_due_at = now()+24h. consent-revoke-cleaner runs hourly with a 1h look-ahead and DELETEs rows, writes CACHE_PURGED audit |
| FR-404 (config-driven providers) | src/config/provider-registry.ts is the only file that changes when a new provider is added. provider-mapping.ts consumes the registry; the lambda code does not need editing |
| PRI-001 LOG | external_assets.consent_audit_log UPDATE/DELETE revoked from bank_platform_app_user at SQL level (see LOCK_DDL in src/shared/db-setup.ts). Audit covers GRANTED, REVOKED, REFRESHED, EXPIRED, RETRIEVAL_HALTED, CACHE_PURGED |
| PRI-003 GATE | Revocation atomically sets revoked_at + cache_purge_due_at. Cleaner's hourly run with 1h look-ahead guarantees deletion within 24h even if a single run is missed. Negative test: poller's isRetrievable() returns false for revoked consent (proven in consent-state.test.ts) |
V1 deferrals¶
| Item | Why | Path forward |
|---|---|---|
| AU superannuation direct provider integration | Spec marks Phase 2 | Add direct provider clients per fund + register entries with treatInvestmentAs: "SUPERANNUATION" |
bank-core SD01 assets write-back consumer |
Owning module not yet built | When SD01 ships its asset-aggregator, subscribe to bank-platform.external_asset_updated and apply into its own assets table |
| Real OAuth 2.0 redirect flow + customer-facing app endpoint | Requires app-side UX + MOD-075 route | V1 exposes consent-api Lambda; the app integration lands when MOD-075 wires the route |
| Push-notification re-auth prompt | Depends on MOD-063 trigger rule | Add a TRIGGER_RULE to MOD-063 listening for external_asset_retrieval_failed once the customer-facing app surfaces the prompt |
| Token rotation / KMS field-level encryption beyond Neon TDE | V1 stores tokens as-is | Wrap with KMS envelope crypto when MOD-045 helper is generalised |
| Token expiry → EXPIRED audit row | V1 logic relies on expires_at <= now check at retrieval time but does not record an EXPIRED row when it transitions |
Tiny background sweep in cleaner |
Tests¶
- 59 unit —
asset.test.ts(12) — canonical validatorprovider-mapping.test.ts(9) — FR-404 mapper invariantsnormalise.test.ts(6) — Akahu → canonicalconsent-state.test.ts(7) — state machine + 24h purge SLAretry.test.ts(9) — FR-401 retry classification + backoffuuid.test.ts(4) — deterministic asset_idconsent-api.test.ts(8) — register/revoke/refresh dispatchercleaner.test.ts(3) — PRI-003 GATE 24h cache deletion- 37 live verification via
scripts/verify-deployment.mjs— Lambdas Active + VPC, schedules ENABLED at the right cadences, EB-PUT permissions on both buses, audit/consent/staging table FQNs published, DLQ KMS-encrypted with 14d retention, all 4 alarms wired, 13 SSM contract paths populated.
FR text vs architecture reconciliation¶
The wiki's FR-402 says the system "writes to the assets and
asset_party_relationships tables via write-back Lambda". This wording
predates the post-MOD-158 architectural rule (modules own their
schemas; cross-domain writes go via inbox + event). The implementation
follows the new rule: this module owns external_assets.assets_staged
and fires bank-platform.external_asset_updated; a future bank-core
SD01 module consumes the event and writes the SD01 tables. The wiki
amendment should rephrase FR-402 to "writes to its own staging table
and publishes a domain event" — coverage table above retains the
original FR mapping for traceability.