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():
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.
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 main2026-04-22 10:15:53 -07:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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-grainedExceptionCodes. Factory helpers (Timing.dispatch,Data.valueMismatch,Control.unauthorized,Business.manualStop, …) keep call sites short and self-documenting.classify(err)normalises anything into aSettlementException, androute()is a deterministic table:errorRecovery)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.executePlannow drives the full 12-state machine throughstateMachine.transition():New
validatePhase()(arch §9.2) reconciles before COMMIT:0x+ 64 hex)issueInstrumentstep amount > 0Mismatches raise
Data.valueMismatch(mismatches)and the machine transitions toABORTED. 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 newactorsparam.api/plans.ts+index.tsNew
GET /api/plans/:planId/statereturns{ transaction_state, legacy_status, transitions: [...] }— the full audit chain from thetransaction_state_transitionstable (arch §14).Tests
tests/unit/exceptionManager.test.ts— 14 cases covering the four-class taxonomy,classify()idempotence, and every routing-matrix edge.Verification
Not in this PR
NotaryRegistry.finalizePlan()still calls the mock — PR C wires the real contract.errorHandler.ts,errorRecovery.ts,deadLetterQueue.ts,gracefulDegradation.tsleft 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.