P0.9.3 — ν Notification Channels — Verification
Step 5 of the 5-step chain. Test evidence, acceptance-criteria checklist, coverage snapshot.
1. Test execution
1.1 Full suite — npm test
Test Suites: 18 passed, 18 total
Tests: 816 passed, 816 total
Snapshots: 0 total
Time: ~30.6s
Exit code: 0. Zero regressions — baseline before this PR was 780 passing; P0.9.3 adds 36 new tests (815 + 1 was a transient from an earlier isolated run; steady-state is 816).
1.2 Scoped run — notifications only
$ npx jest --testPathPattern="notifications\.test\.ts"
Test Suites: 1 passed, 1 total
Tests: 36 passed, 36 total
1.3 Lint — npm run lint
$ npm run lint
> colibri@0.0.1 lint
> eslint src
(clean — no warnings, no errors)
Exit code: 0.
1.4 Type check — npm run build
$ npm run build
> colibri@0.0.1 build
> tsc
(clean — compiles with strict + noUncheckedIndexedAccess + exactOptionalPropertyTypes)
Exit code: 0.
2. Coverage snapshot
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
--------------------------|---------|----------|---------|---------|-------------------
src/domains/integrations | | | | |
notifications.ts | 97.14 | 61.9 | 100 | 97.14 | 217
- The only uncovered line is L217 — the defensive outer
catchinnotify()that fires only if Zod or the env read itself throws synchronously. Documented in the source as “unreachable in normal operation”; the alternative (removing it) would break the fire-and-forget invariant. - Branch coverage (61.9%) is held down by unreachable safety branches. All user-visible branches (notifier-present / -absent, env set / unset, fetch ok / 4xx / 5xx / throws, event valid / invalid) are fully exercised.
3. Acceptance-criteria checklist (1:1 from contract §6)
| # | Criterion | Evidence |
|---|---|---|
| 1 | notify(event, payload) dispatches event to configured channels |
notify — payload-per-event-type coverage (3× test.each cases — all channels fire per event) |
| 2 | Channels: log (always), mcp (MCP notification), webhook (optional) |
notify — log channel (4 tests), notify — mcp channel (5 tests), notify — webhook channel (7 tests) |
| 3 | COLIBRI_WEBHOOK_URL enables webhook; COLIBRI_* only, AMS_* not read |
notify — webhook channel › skips POST when unset + notify — webhook channel › POSTs when set + config — COLIBRI_WEBHOOK_URL › still rejects AMS_* (regression) |
| 4 | Events: task.completed, merkle.finalized, error.critical (no agent.spawned) |
notificationEventSchema block — 7 tests including rejects agent.spawned |
| 5 | Fire-and-forget: failures never block main execution | notify — fire-and-forget invariant block — 5 tests (notifier throws, fetch throws, fetch 500, invalid event, all-three-fail); all asserted via await expect(...).resolves.toBeUndefined() |
| 6 | Each channel gets correct payload per event type | notify — payload-per-event-type coverage using test.each over all 3 events, asserting log line + notifier params + webhook body per event |
All 6 criteria: ✓
4. Acceptance: scoped test list (36 total)
notificationEventSchema
✓ accepts task.completed payload
✓ accepts merkle.finalized payload
✓ accepts error.critical payload
✓ rejects agent.spawned (not a Phase 0 event)
✓ rejects missing discriminator
✓ rejects task.completed missing task_id
✓ rejects merkle.finalized missing root
notify — log channel
✓ writes single stderr line with type prefix and JSON payload
✓ log channel fires for every event type
✓ does not touch process.stdout
✓ dispatchLog directly produces the same line shape
✓ dispatchLog swallows a throwing logger without rejecting
notify — mcp channel
✓ calls notifier.notification with notifications/colibri/event method
✓ passes the event through as notification params
✓ degrades to stderr log when notifier is omitted
✓ swallows notifier throw and logs a failure line
✓ dispatchMcp directly emits once with the correct shape
notify — webhook channel
✓ skips POST when COLIBRI_WEBHOOK_URL is unset
✓ POSTs application/json when COLIBRI_WEBHOOK_URL is set
✓ body is JSON.stringify(event)
✓ swallows network error and logs a failure line
✓ logs (but does not throw) on a non-2xx response
✓ dispatchWebhook directly POSTs when env is set
✓ dispatchWebhook skips silently when env is empty string
notify — fire-and-forget invariant
✓ resolves even when notifier rejects
✓ resolves even when fetch rejects
✓ resolves even when fetch returns 500
✓ resolves and logs when event is invalid
✓ all three channels failing in concert still resolves
notify — payload-per-event-type coverage
✓ each channel receives correct payload for task.completed
✓ each channel receives correct payload for merkle.finalized
✓ each channel receives correct payload for error.critical
config — COLIBRI_WEBHOOK_URL
✓ loadConfig accepts a valid URL
✓ loadConfig rejects a non-URL string
✓ loadConfig leaves the field undefined when unset
✓ loadConfig still rejects AMS_* namespace (regression)
5. Artefacts produced
| Path | Purpose |
|---|---|
docs/audits/notifications-audit.md |
Step 1 — surface inventory |
docs/contracts/notifications-contract.md |
Step 2 — behavioral contract |
docs/packets/notifications-packet.md |
Step 3 — execution plan |
docs/verification/notifications-verification.md |
Step 5 — this file |
src/config.ts |
+14 lines — COLIBRI_WEBHOOK_URL: z.string().url().optional() with JSDoc |
src/domains/integrations/notifications.ts |
+316 lines — notify + schema + 3 dispatchers |
src/domains/integrations/index.ts |
+9 lines — barrel |
src/__tests__/domains/integrations/notifications.test.ts |
+479 lines — 36 tests |
6. Known non-goals / deferred work
Deferred from donor spec per task-breakdown.md scope and ADR-005 (contract §7):
- HMAC-SHA256 body signing
- Exponential retry / circuit-breaker
- LRU notification history (capped at 200)
- Secret redaction regex
- Per-channel opt-in flags
- Slack / Discord / Email adapters
agent.spawnedevent (no agent runtime)- Wiring callers in β / η / α (separate follow-up tasks — this PR ships the library only)
- Registering a
notify_eventMCP tool — not in scope (tool-surface target 60-80 is tracked elsewhere)
7. Known issue — not introduced by this task
src/__tests__/startup.test.ts intermittently fails to compile in an isolated npx jest --testPathPattern="startup" run with the error “import.meta is only allowed when –module is es2022 etc.” When run as part of the full suite (npm test), it passes. This pre-dates P0.9.3 — flagged in MEMORY.md: “Pre-existing intermittent startup-subprocess smoke test flakiness in CI (noted by PR #138 agent; pre-dates rename)”. Verified reproducible on the base commit (dc660381) before any P0.9.3 changes.
8. Donor-bug mitigations upheld
- #3 — stdout sanctity: the
logchannel writes to stderr only. Verified by thedoes not touch process.stdouttest which shimsprocess.stdout.writefor the duration of anotify()call and asserts zero invocations. - #5 — fire-and-forget:
notify()never rejects. Verified across 5 explicit fire-and-forget tests plus the 3-event × 3-channel matrix tests. No path in the implementation propagates an error to the caller.
9. Decisions recorded
- Coupling: the
Notifierinterface is a minimal structural contract.notifydoes NOT importColibriServerContext; callers construct an inline Notifier fromctx.server.serverat the callsite. Dependency direction: ν → α (one-way). - Event payloads: ISO-8601 timestamps (matches ζ Decision Trail, JSON-safe, human-readable).
- Notification method:
notifications/colibri/event(custom namespace; no collision with SDK-standard notifications). - Concurrency:
Promise.allSettledover the 3 dispatchers — deterministic for tests, still “fire-and-forget” for the caller (one resolved promise). - No
jest.fn/jest.spyOn: ts-jest + ESM does not reliably exposejestas a global; tests use manual stubs. Confirmed same pattern elsewhere in the suite (e.g.db-init.test.tsimports from@jest/globalsbut the notifications tests avoid it entirely to stay minimal).
10. Final gate status
- All 5-step commits exist and are in order.
npm test— 816 / 816 green.npm run lint— clean.npm run build— clean.- Notifications test block — 36 / 36 green.
- Zero regressions against baseline.
- All 6 acceptance criteria covered by tests.
- No files outside the task’s scope modified (checked with
git status --porcelainbefore commit —src/tools/merkle.tsandsrc/domains/proof/retention.tsuntouched as required for the parallel P0.8.2/P0.8.3 workstreams).
Step 5 EXIT. Ready for PR.