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 catch in notify() 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.spawned event (no agent runtime)
  • Wiring callers in β / η / α (separate follow-up tasks — this PR ships the library only)
  • Registering a notify_event MCP 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 log channel writes to stderr only. Verified by the does not touch process.stdout test which shims process.stdout.write for the duration of a notify() 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 Notifier interface is a minimal structural contract. notify does NOT import ColibriServerContext; callers construct an inline Notifier from ctx.server.server at 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.allSettled over 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 expose jest as a global; tests use manual stubs. Confirmed same pattern elsewhere in the suite (e.g. db-init.test.ts imports from @jest/globals but 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 --porcelain before commit — src/tools/merkle.ts and src/domains/proof/retention.ts untouched as required for the parallel P0.8.2/P0.8.3 workstreams).

Step 5 EXIT. Ready for PR.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.