PR B: VALIDATING phase + unified ExceptionManager (arch steps 3, 7) #6

Merged
nsatoshi merged 0 commits from devin/1776875351-validating-exception-manager into main 2026-04-22 10:15:59 -07:00
Owner

Implements steps 3 and 7 from the architecture gap-analysis. Stacks on PR A.

What lands

services/exceptionManager.ts (new)

One file, one taxonomy. Architecture note §12 collapsed into ExceptionClass = 'timing' | 'data' | 'control' | 'business' | 'system' + the 18 fine-grained ExceptionCodes. Factory helpers (Timing.dispatch, Data.valueMismatch, Control.unauthorized, Business.manualStop, …) keep call sites short and self-documenting. classify(err) normalises anything into a SettlementException, and route() is a deterministic table:

class decision
timing retry (exponential backoff via errorRecovery)
system/network_error retry
system/other dead_letter
data abort_transaction
control/duplicate_event dead_letter
control/other escalate
business/manual_stop abort_transaction
business/other escalate

handle() is the single entry point callers should use — it does classify → route → retry-or-DLQ and returns the decision so the coordinator can still decide to abort.

services/execution.ts (refactored, 68% rewrite)

ExecutionCoordinator.executePlan now drives the full 12-state machine through stateMachine.transition():

DRAFT -> INITIATED -> PRECONDITIONS_PENDING -> READY_FOR_PREPARE
      -> PREPARED (approver) -> EXECUTING (releaser)
      -> VALIDATING -> COMMITTED (approver) -> CLOSED
failure path: VALIDATING|* -> ABORTED -> CLOSED

New validatePhase() (arch §9.2) reconciles before COMMIT:

  • DLT tx hash format (0x + 64 hex)
  • bank message id non-empty
  • every non-issueInstrument step amount > 0

Mismatches raise Data.valueMismatch(mismatches) and the machine transitions to ABORTED. SoD-gated edges use distinct default actors (system-approver, system-releaser, system-validator) so the matrix in PR A stays satisfied; production callers pass real identities via the new actors param.

api/plans.ts + index.ts

New GET /api/plans/:planId/state returns { transaction_state, legacy_status, transitions: [...] } — the full audit chain from the transaction_state_transitions table (arch §14).

Tests

tests/unit/exceptionManager.test.ts — 14 cases covering the four-class taxonomy, classify() idempotence, and every routing-matrix edge.

Verification

$ npx tsc --noEmit              # clean
$ npx jest                      # 45 passed, 3 suites

Not in this PR

  • Events are still emitted via in-memory EventEmitter — PR D adds the signed, persisted, SSE-exposed event bus.
  • NotaryRegistry.finalizePlan() still calls the mock — PR C wires the real contract.
  • errorHandler.ts, errorRecovery.ts, deadLetterQueue.ts, gracefulDegradation.ts left in place; ExceptionManager consumes them rather than replacing them, so HTTP error middleware is unchanged.

Series order

A → B → C → D → E → F → G → H.

Implements **steps 3 and 7** from the architecture gap-analysis. Stacks on PR A. ## What lands ### `services/exceptionManager.ts` (new) One file, one taxonomy. Architecture note §12 collapsed into `ExceptionClass = 'timing' | 'data' | 'control' | 'business' | 'system'` + the 18 fine-grained `ExceptionCode`s. Factory helpers (`Timing.dispatch`, `Data.valueMismatch`, `Control.unauthorized`, `Business.manualStop`, …) keep call sites short and self-documenting. `classify(err)` normalises anything into a `SettlementException`, and `route()` is a deterministic table: | class | decision | | --- | --- | | timing | retry (exponential backoff via `errorRecovery`) | | system/network_error | retry | | system/other | dead_letter | | data | abort_transaction | | control/duplicate_event | dead_letter | | control/other | escalate | | business/manual_stop | abort_transaction | | business/other | escalate | `handle()` is the single entry point callers should use — it does classify → route → retry-or-DLQ and returns the decision so the coordinator can still decide to abort. ### `services/execution.ts` (refactored, 68% rewrite) `ExecutionCoordinator.executePlan` now drives the full 12-state machine through `stateMachine.transition()`: ``` DRAFT -> INITIATED -> PRECONDITIONS_PENDING -> READY_FOR_PREPARE -> PREPARED (approver) -> EXECUTING (releaser) -> VALIDATING -> COMMITTED (approver) -> CLOSED failure path: VALIDATING|* -> ABORTED -> CLOSED ``` New `validatePhase()` (arch §9.2) reconciles before COMMIT: - DLT tx hash format (`0x` + 64 hex) - bank message id non-empty - every non-`issueInstrument` step amount > 0 Mismatches raise `Data.valueMismatch(mismatches)` and the machine transitions to `ABORTED`. SoD-gated edges use distinct default actors (`system-approver`, `system-releaser`, `system-validator`) so the matrix in PR A stays satisfied; production callers pass real identities via the new `actors` param. ### `api/plans.ts` + `index.ts` New `GET /api/plans/:planId/state` returns `{ transaction_state, legacy_status, transitions: [...] }` — the full audit chain from the `transaction_state_transitions` table (arch §14). ### Tests `tests/unit/exceptionManager.test.ts` — 14 cases covering the four-class taxonomy, `classify()` idempotence, and every routing-matrix edge. ## Verification ``` $ npx tsc --noEmit # clean $ npx jest # 45 passed, 3 suites ``` ## Not in this PR - Events are still emitted via in-memory EventEmitter — PR D adds the signed, persisted, SSE-exposed event bus. - `NotaryRegistry.finalizePlan()` still calls the mock — PR C wires the real contract. - `errorHandler.ts`, `errorRecovery.ts`, `deadLetterQueue.ts`, `gracefulDegradation.ts` left in place; ExceptionManager consumes them rather than replacing them, so HTTP error middleware is unchanged. ## Series order A → **B** → C → D → E → F → G → H.
nsatoshi changed target branch from devin/1776874611-instrument-leg-state-machine to main 2026-04-22 10:15:53 -07:00
nsatoshi merged commit b4d28c77d8 into main 2026-04-22 10:15:59 -07:00
nsatoshi deleted branch devin/1776875351-validating-exception-manager 2026-04-22 10:16:02 -07:00
Sign in to join this conversation.