Implements step 5 from the architecture gap-analysis — replaces the in-memory EventEmitter pattern with a typed, signed, hash-chained, persisted event journal + SSE endpoint. Stacks on PR B.
EVENT_TYPES — all 15 normalised categories from arch §7.2. Single source of truth for subscribers.
publish({ planId, type, actor?, payload? }) — hashes the payload, reads the last signature for the plan, signs the new event, INSERTs, emits to the in-process EventEmitter.
verifyChain(planId) — replays the chain and returns { ok: true } or { ok: false, brokenAt, reason } on the first payload / signature / prev_hash mismatch.
getEventsForPlan(planId) — chronological replay.
subscribe(planId, cb) — returns an unsubscribe fn. Used by the SSE route.
HMAC secret precedence: EVENT_BUS_HMAC_SECRET → SESSION_SECRET → dev default.
api/plans.ts
GET /api/plans/:planId/events — full trail. ?verify=1 runs verifyChain and returns { chain_valid, broken_at?, broken_reason? } so UIs can flag tampered audit logs.
GET /api/plans/:planId/events/stream — SSE. On connect we replay history then subscribe to live events; 15s keep-alive; clean unsubscribe on client disconnect.
ExecutionCoordinator still uses its own in-process EventEmitter for status events. PR A's state machine already records source_event_id on every transition; PR E (next) will wire ExecutionCoordinator to eventBus.publish() so every §7.2 event flows through the signed journal and every state transition can be joined back to its source event.
The in-process subscriber does not fan out across replicas — for multi-replica orchestrators the publish() call would additionally broadcast to NATS/Kafka, but the persistence layer and hash chain stay unchanged.
Series order
A → B → C → D → E → F → G → H.
Base: devin/1776875351-validating-exception-manager (PR B). The diff shown here is D-only.
Implements **step 5** from the architecture gap-analysis — replaces the in-memory `EventEmitter` pattern with a typed, signed, hash-chained, persisted event journal + SSE endpoint. Stacks on PR B.
## What lands
### `db/migrations/003_events.ts` — append-only journal
| column | type | note |
| --- | --- | --- |
| id | UUID | pk |
| plan_id | UUID | FK → plans(plan_id), cascade delete |
| type | VARCHAR(128) | one of arch §7.2 |
| actor | VARCHAR(255) | nullable (system events) |
| payload | JSONB | arbitrary event body |
| payload_hash | CHAR(64) | sha256(JSON(payload)) |
| prev_hash | CHAR(64) | signature of previous event for same plan |
| signature | CHAR(64) | hmac_sha256(secret, plan_id\|type\|payload_hash\|prev_hash) |
| created_at | TIMESTAMPTZ | default now() |
Indexed on `(plan_id, created_at)` and `(type)`.
### `services/eventBus.ts`
- `EVENT_TYPES` — **all 15** normalised categories from arch §7.2. Single source of truth for subscribers.
- `publish({ planId, type, actor?, payload? })` — hashes the payload, reads the last signature for the plan, signs the new event, INSERTs, emits to the in-process `EventEmitter`.
- `verifyChain(planId)` — replays the chain and returns `{ ok: true }` or `{ ok: false, brokenAt, reason }` on the first payload / signature / prev_hash mismatch.
- `getEventsForPlan(planId)` — chronological replay.
- `subscribe(planId, cb)` — returns an unsubscribe fn. Used by the SSE route.
HMAC secret precedence: `EVENT_BUS_HMAC_SECRET` → `SESSION_SECRET` → dev default.
### `api/plans.ts`
- `GET /api/plans/:planId/events` — full trail. `?verify=1` runs `verifyChain` and returns `{ chain_valid, broken_at?, broken_reason? }` so UIs can flag tampered audit logs.
- `GET /api/plans/:planId/events/stream` — SSE. On connect we replay history then subscribe to live events; 15s keep-alive; clean unsubscribe on client disconnect.
### Tests
`tests/unit/eventBus.test.ts` — 9 cases: enum completeness, first-event null prev_hash, multi-event chain, per-plan isolation, untampered chain passes, three tamper-detection cases (payload / signature / prev_hash mutation).
## Verification
```
$ npx tsc --noEmit # clean
$ npx jest # 60 passed, 5 suites
```
## Not in this PR
- ExecutionCoordinator still uses its own in-process `EventEmitter` for `status` events. PR A's state machine already records `source_event_id` on every transition; PR E (next) will wire `ExecutionCoordinator` to `eventBus.publish()` so every §7.2 event flows through the signed journal and every state transition can be joined back to its source event.
- The in-process subscriber does not fan out across replicas — for multi-replica orchestrators the `publish()` call would additionally broadcast to NATS/Kafka, but the persistence layer and hash chain stay unchanged.
## Series order
A → B → C → **D** → E → F → G → H.
Base: `devin/1776875351-validating-exception-manager` (PR B). The diff shown here is D-only.
nsatoshi
changed target branch from devin/1776875351-validating-exception-manager to main2026-04-22 10:16:07 -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 step 5 from the architecture gap-analysis — replaces the in-memory
EventEmitterpattern with a typed, signed, hash-chained, persisted event journal + SSE endpoint. Stacks on PR B.What lands
db/migrations/003_events.ts— append-only journalIndexed on
(plan_id, created_at)and(type).services/eventBus.tsEVENT_TYPES— all 15 normalised categories from arch §7.2. Single source of truth for subscribers.publish({ planId, type, actor?, payload? })— hashes the payload, reads the last signature for the plan, signs the new event, INSERTs, emits to the in-processEventEmitter.verifyChain(planId)— replays the chain and returns{ ok: true }or{ ok: false, brokenAt, reason }on the first payload / signature / prev_hash mismatch.getEventsForPlan(planId)— chronological replay.subscribe(planId, cb)— returns an unsubscribe fn. Used by the SSE route.HMAC secret precedence:
EVENT_BUS_HMAC_SECRET→SESSION_SECRET→ dev default.api/plans.tsGET /api/plans/:planId/events— full trail.?verify=1runsverifyChainand returns{ chain_valid, broken_at?, broken_reason? }so UIs can flag tampered audit logs.GET /api/plans/:planId/events/stream— SSE. On connect we replay history then subscribe to live events; 15s keep-alive; clean unsubscribe on client disconnect.Tests
tests/unit/eventBus.test.ts— 9 cases: enum completeness, first-event null prev_hash, multi-event chain, per-plan isolation, untampered chain passes, three tamper-detection cases (payload / signature / prev_hash mutation).Verification
Not in this PR
EventEmitterforstatusevents. PR A's state machine already recordssource_event_idon every transition; PR E (next) will wireExecutionCoordinatortoeventBus.publish()so every §7.2 event flows through the signed journal and every state transition can be joined back to its source event.publish()call would additionally broadcast to NATS/Kafka, but the persistence layer and hash chain stay unchanged.Series order
A → B → C → D → E → F → G → H.
Base:
devin/1776875351-validating-exception-manager(PR B). The diff shown here is D-only.18bdaf61d5to59e1a85267