Architecture Alignment — Work Log (audit trail)
Running, append-only log of every step taken on the fix/architecture-alignment
branch, so the work can be audited later. Plan + workstream definitions live in
ARCHITECTURE_ALIGNMENT_PLAN.md.
Branch: fix/architecture-alignment (based on origin/dev-v1.0.0).
Git policy: commits ONLY when the user explicitly asks. Nothing pushed.
Legend: ✅ done+verified · 🚧 in progress · ⬜ todo
Local commits so far (NOT pushed)
| Ref | Title | Contents |
|---|---|---|
743ca62 | feat(equb,resilience): partner-bank escrow flow + custody gate + gRPC breakers | vision-alignment pass, W1.1, W1.2, W6, W8 |
e124995 | feat(equb): support both payout methods — RANDOM lottery + FIXED rotation | payoutMethod domain+schema+repo+usecase+tests |
735e30c | feat(equb): expose both payout methods on the HTTP API | controller/DTO wiring |
(3 commits — all local. After these the user instructed: commit/push/add to GitHub ONLY when asked.)
Step-by-step log
S0 — Vision alignment pass ✅
- Equb custody gate:
EQUB_MONEY_MOVEMENT_ENABLED(default off, forbidden in prod via configsuperRefine);DisabledEqubLedgerAdapterseals the singleEQUB_LEDGER_PORTchokepoint. - Wallet terminology clarified as a ledger read-model (NO rename — auditability/ADR-009): comments in
private-equb-contribution.service.ts,me-equb.controller.ts. - Docs → one story: README Equb status, CLAUDE.md Equb-gate fact, archive-doc positioning banner.
- Files:
packages/shared/config/src/lib/config.ts,apps/api/src/products/equb/{disabled-equb-ledger.adapter.ts,equb-api.module.ts}, README, CLAUDE.md,docs/archive/SYSTEM_DOCUMENTATION.md. - Verify: api app typecheck 0 errors; shared-config tests pass.
W1.1 — Escrow foundation + verified binding ✅
- bank-sandbox: seeded
FAKE-EQUB-ESCROW-001(services/bank-sandbox/internal/store/store.go). - Seed: demo
EqubCycle(DRAFT) +PartnerBankEscrowBinding→DASHEN/FAKE-EQUB-ESCROW-001(apps/api/prisma/seed.ts). equb-escrow.controller.tscreateBindingnow verifies the account at the partner viaIdentityLookupPort(same pattern as employer payroll-bank linking) before ACTIVE; rejects unknown/invalid, 409 on partner-unavailable.- Side-fix:
prisma generate(stale client from the merged attendance migration caused 13 false errors). - Verify: api app typecheck 0 errors.
W1.2 — CORPORATE contribution leg → escrow ✅
payroll-equb-fanout.service.ts: requires an ACTIVE escrow binding (else skip — "escrow required" gate); postsequb:poolmirror PENDING; moves money employer-source → escrow via EWADisbursementPort; confirms/fails the mirror on the bank's answer.EqubLedgerPortgainedconfirmContribution/failContribution;EqubContributionInput.status(PENDING for escrow-backed). Threaded throughRecordEqubContribution. Disabled adapter refuses these too (gate holds).- Updated 4 package test fakes + fanout spec; added "no escrow binding → skip" test.
- Verify: api app + specs + equb specs typecheck 0; equb unit tests 44 pass.
W6 — gRPC circuit breakers + deadlines ✅
apps/api/src/_infra/resilience/circuit-breaker.ts(open after N failures, half-open probe, recover); per-call deadlines on ledger + integration-gateway clients so a hung partner fails fast → transfer stays PENDING for the poller.- Verify: 4 breaker unit tests pass; api typecheck 0.
W8 — Broker decision ✅
docs/adr/ADR-017-event-transport-postgres-outbox.md: Postgres outbox + table-poller is the spine; Kafka dormant; RabbitMQ not adopted. CLAUDE.md ADR range bumped to 017.
Both payout methods (RANDOM + FIXED) ✅
- Driven by the employer dashboard design (Fixed Order vs Random Auto-Assignment), which was a UI-only mock — backend was random-only.
- Domain:
EqubCycle.payoutMethod('RANDOM' default | 'FIXED') +selectFixedWinner()(eligible member with lowestdrawOrder; employer order applied viaactivate'sdrawOrderShuffle; no reveal). AssignFixedWinnerUseCase(FIXED counterpart toRunEqubDraw; emits sameequb.draw_completed.v1).CreateEqubCycle+ repo persistpayoutMethod. Migration20260618130000_equb_payout_method(column default RANDOM + CHECK) — applied to local DB. Column-add only → inherits existing tenant RLS (no RLS change).- API:
POST /equb/cyclesacceptspayoutMethod;POST /equb/cycles/:id/drawbranches RANDOM (seed+nonce) vs FIXED (drawOrder). - Verify: equb pkg + api + api specs typecheck 0; 4 FIXED-payout domain tests pass.
- Finding: employer-web
corporate-equb/createpage is a UI mock (handleNextjust navigates; no API call) — end-to-end UI wiring is a separate frontend task.
Test infra — fix the better-auth ESM transform failure ✅
- Root cause:
better-auth/pluginsis ESM-only (dist/plugins/index.mjs); ts-jest ignores node_modules → any spec transitively importing it ("Cannot use import statement outside a module") failed to load. OnlycreateAccessControlis used (packages/shared/auth/src/lib/permissions.ts), at module load. - Fix:
test/mocks/better-auth-plugins.cjsstub +moduleNameMapperinpackages/equb/jest.config.ctsandapps/api/jest.config.cts. - Result: equb suite fully green — 9 suites, 69 tests (was 4 suites / 48, with 5 dead). My apps/api specs (fanout, private-contribution, circuit-breaker) green — 19 tests. Fixed my own new fanout test (needed ≥2 members for roundCount≥2).
- Honest note: fixing the transform revealed pre-existing api test-harness failures that were previously hidden behind "failed to load" — supertest specs (
pension/platform/tax-rule-admin) fail onthis.prisma.adminProfile.findFirst is not a function(incomplete prisma mock) andorg-role.guard.speconmember.roleAssignmentsundefined (incomplete member mock). These predate this work, are unrelated to the Equb changes, and are separate test-harness debt. The api suite is also slow (supertest boots Nest per spec). NOT regressions — those suites couldn't run at all before.
W1.4 — Payout leg (escrow → winner) ✅
EqubPayoutInput.status+postPayouthonors it (PENDING mirror);CloseEqubRoundPayoutacceptsstatus.- New
apps/api/src/products/equb/equb-escrow-payout.service.ts: resolves the current round's drawn winner, requires an ACTIVE escrow binding (source), resolves the winner'sEmployee.bankAccount(blocksEQUB_WINNER_NO_BANK_ACCOUNTif unset), posts the payout mirror PENDING viaCloseEqubRoundPayout(compliance gate + round advance inside), transfers escrow → winner via EWADisbursementPort, then confirm/fail the mirror (reusingconfirmContribution/failContribution— settlement-generic flip-by-id). - Endpoint:
POST /equb/escrow/cycles/:id/payout(@MoneyMoving, Idempotency-Key required). Works for BOTH RANDOM + FIXED winners (winner already chosen by/draw). - Wired in
equb-api.module.ts. - Verify: equb pkg + api app + api specs typecheck 0; equb tests 48 pass (incl. 4 fixed-payout). The 5 fail-to-run suites are the pre-existing
better-authESM transform issue (not assertions, not mine). - Note: round advances on payout even if the transfer is still PENDING (mirror confirmed async) — same shape as the contribution leg; full compensation on FAILED is future work.
W1.3 — async settlement confirm ✅
- Schema:
equb_contribution.partnerReference;equb_round.payoutLedgerTransactionId+payoutPartnerReference(+ indexes). Migration20260618140000_equb_settlement_tracking— applied to local DB. Nullable; sync-confirm path unaffected. - Persist
providerRef(+ the realledgerTransactionId, which the repo left null): fanout updates the contribution row after disburse;EqubEscrowPayoutServiceupdates the round after disburse. bank-settlement-applier.service.ts: new Equb branch — matchesequb_contribution/equb_roundbypartnerReference(cross-tenant, RLS-bypass connection), then confirm/fail the PENDING mirror via the ledger (idempotent; webhook+poller race safe) + audit row.BankSettlementResult.domain+ webhook response type +bumpApplygainedEQUB/equb.- Fixed the payroll applier spec constructor (new
prismaparam) + fanout spec prisma mock (equbContribution.update). - Verify: api app + specs typecheck 0; equb 69 tests pass; integration applier 5 pass; fanout/private/applier-payroll specs 20 pass.
W1 core money flow is now complete for CORPORATE: contribute → escrow → draw (RANDOM|FIXED) → payout escrow→winner, with both synchronous and async (webhook/poller) settlement.
W1.6 — PRIVATE contribution onto escrow ✅
PrivateEqubContributionService.contributeFromWalletrefactored to the escrow path: requires an ACTIVE escrow binding; the member funds from their ownEmployee.bankAccount→ escrow (real bank money, not a simulated wallet); posts the mirror PENDING; gateway transfer; confirm/fail; persists partnerReference (W1.3). Removed the deadpoolAccounthelper.- This retires the wallet-as-stored-value concern — PRIVATE money is a real member-bank → partner-escrow transfer; "wallet" remains only a ledger read-model label.
- Spec rewritten (5 deps + binding/employee/disburse mocks) + 2 new gate tests (no binding, no member bank account). 11 tests pass; typecheck clean.
W1.5 — retire the prod-block ✅ (option A)
- Removed the legacy simulated-pool endpoints
POST /api/equb/cycles/:id/contributeand:id/payoutfromequb.controller.ts(+ their use-case injections, DTOsRecordEqubContributionDto/CloseEqubPayoutDto, and the now-deadMoneyMoving/Headersimports +EqubContributionAmountMismatchErrormap branch). No external callers (no verify scripts, no controller spec). The use cases stay in the module (used by fanout / me-equb / escrow-payout). - Single money path now: contributions via fan-out (CORPORATE) / me-equb (PRIVATE), payouts via
/equb/escrow/cycles/:id/payout— all require an ACTIVE escrow binding. - Retired the boot-time prod-block: removed the
superRefinethat forbadeEQUB_MONEY_MOVEMENT_ENABLED=truein production. The per-cycle escrow binding is now the gate (not a boot block); updated the flag doc +.env.example. - Verify: shared-config 6, equb 69, api equb/resilience/applier 31 — all pass; typecheck clean across config + equb + api + specs.
✅ W1 COMPLETE. Equb is end-to-end partner-bank-escrow-backed, no custody, both payout methods, sync + async settlement, single money path, prod-enableable per-cycle.
W2 — reconciliation reader (ledger vs partner-bank) ✅
apps/api/src/products/equb/bank-sandbox-statement-reader.ts—BankSandboxPartnerBankStatementReaderreads the escrow account's balance from bank-sandbox over HTTP+HMAC (same wire shape as the identity-lookup adapter), returnsbigint | null.- Bound in
equb-api.module.tsvia a config-driven factory: bank-sandbox reader whenBANK_SANDBOX_BASE_URL+BANK_SANDBOX_SIGNING_KEYare set, else the stub (same swap-point as banking IdentityLookup). This unblocksEqubEscrowReconciliationWorker— it can now pull the partner-bank balance and compare to theequb:poolmirror (bank wins on drift). ManualPOST /equb/escrow/bindings/:id/reconcilealready exists as the operator path. - Verify: api app + specs typecheck 0; 5 reader unit tests pass (balance→bigint, HMAC headers, not-found→null, unreachable→null, malformed→null).
- Note: bank-sandbox returns CURRENT balance (no historical statements) → same-day recon only in the sandbox; the real partner adapter honours the statement date.
W5 — leader election for non-idempotent schedulers ✅
apps/api/src/_infra/scheduling/leader-election.service.ts—LeaderElectionService.tryRunAsLeader(lockKey, label, fn)usingpg_try_advisory_xact_lock(transaction-scoped → auto-releases on settle/crash, non-blocking → non-leaders skip).LEADER_LOCK_KEYSfor the 3 jobs.SchedulingModule(@Global) imported in AppModule.- Wired into all 3 non-idempotent cron workers (equb escrow recon, payroll auto-lock, court-order auto-submit): the scheduled tick runs under leader election (one pod per tick); every instance still reschedules so any can take over;
drain()stays directly callable (admin/tests). Leader injected@Optional→ positional test construction unaffected. Idempotent SKIP-LOCKED money pollers deliberately left multi-instance. - Verify: api app + specs typecheck 0; leader-election 3 tests + auto-lock + court-submit specs green (15 pass, 3 integration skipped).
- Trade-off documented: the leader holds one transaction open for the job's duration (fine for these low-frequency crons).
W3 — notifications wired → live ✅ (code) / ops enablement remains
- The senders (SMS logger/http/smtp/ethio-telecom + email) + consumer + dispatcher already existed and worked — notifications only covered EWA settlement.
- Added
EqubPayoutCompletedHandler(equb.payout_completed.v1→ "you won the Equb round, ETB N is on its way" SMS to the winner), registered via theNOTIFICATION_HANDLERSmulti-provider. So notifications now cover the Equb domain we built. - Verify: api app + specs typecheck 0; handler + dispatcher specs 12 tests pass.
- Going truly "live" is an ops step (not code): set
NOTIFICATIONS_CONSUMER_ENABLED=true+SMS_PROVIDER=ethio-telecom(+ SMSC creds) + sign off copy. Until then it logs via the dev sender.
W4 — DLQ + alerting on stuck events ✅ (no migration)
- Insight: retry-exhausted rows (
status=FAILED, attemptCount >= max) inpayroll_deduction_event_state+notification_event_stateARE the dead-letter queue — they already exist, just invisible. No new table. dead-letter/dead-letter.service.ts—summary()(exact counts + recent items per consumer, refreshes the gauge) +requeue(consumer, eventId)(reset attemptCount=0 → poller re-claims).DeadLetterController(GET /api/admin/dead-letter,POST /api/admin/dead-letter/:consumer/:eventId/requeue,@RequirePlatformAdmin).DeadLetterModulewired in AppModule + the controller added toSessionMiddleware.forRoutes(the rake).- Alerting:
demozpay_dead_letter_events_total{consumer}gauge (alert on > 0), refreshed on the admin GET. - Verify: api app + specs typecheck 0; 3 service tests pass.
W7 — ADR-011 cleanup (cross-domain DI → events) ✅ (decision; staged refactor to follow)
- Finding: the hard rule (
packages/A⇏packages/B) already holds — no domain-to-domain imports exist. The cross-domain adapters all live inapps/api(app-layer composition, which ADR-011 already permits). The real gap was an unclassified read-path. - Amended
ADR-011with three shapes: (1) reaction → async event (unchanged); (2) synchronous regulatory gate (KYC/sanctions at disburse/payout) → REQUIRED synchronous + fail-closed, endorsed, must not be eventualised (kyc-read / kyc-sanctions-screen / equb-kyc / equb-sanctions adapters are correct as-is); (3) cross-domain calculation read → PREFER an event-fed projection (candidates: payroll-accrued-earnings, lending income, equb-behavior-signal adapters), with staged per-edge migration guidance. - Doc-only. The projection migrations are independently-shippable follow-ons the ADR now prioritises; none required for correctness today.
✅ ALL WORKSTREAMS COMPLETE (W1–W8)
| WS | Status |
|---|---|
| W1 Equb partner-bank escrow (1.1–1.6) | ✅ |
| W2 Reconciliation reader | ✅ |
| W3 Notifications (Equb handler; ops enablement remains) | ✅ |
| W4 DLQ + alerting | ✅ |
| W5 Leader election | ✅ |
| W6 Circuit breakers | ✅ |
| W7 ADR-011 amendment (projections = staged follow-on) | ✅ |
| W8 Broker decision (ADR-017) | ✅ |
Remaining (see plan)
- ⬜ W1.4 payout leg (in progress) · W1.3 async settlement confirm · W1.6 PRIVATE onto escrow · W1.5 retire prod-block
- ⬜ W2 reconciliation reader · W3 notifications · W4 DLQ · W5 leader election · W7 ADR-011 projections
- ⬜ End-to-end UI wiring for the corporate-equb create flow (separate frontend task)