P0.9.3 — ν Notification Channels — Contract

Step 2 of the 5-step chain. Behavioral contract, type shapes, channel semantics, and acceptance-criteria → test mapping. This file gates Step 3 (packet) and, transitively, Step 4 (implementation).

1. Event type

A notification event is a Zod discriminated union of three variants. Timestamps are ISO-8601 strings (new Date().toISOString()), chosen because (a) they are already the canonical format in the ζ Decision Trail (src/domains/trail/) and (b) they survive JSON serialization without loss — a requirement for the webhook channel.

const TaskCompleted = z.object({
  type: z.literal('task.completed'),
  task_id: z.string().min(1),
  at: z.string().min(1),  // ISO-8601
});

const MerkleFinalized = z.object({
  type: z.literal('merkle.finalized'),
  session_id: z.string().min(1),
  root: z.string().min(1),  // hex-encoded Merkle root
  at: z.string().min(1),
});

const ErrorCritical = z.object({
  type: z.literal('error.critical'),
  message: z.string().min(1),
  at: z.string().min(1),
});

const NotificationEvent = z.discriminatedUnion('type', [
  TaskCompleted,
  MerkleFinalized,
  ErrorCritical,
]);

export type NotificationEvent = z.infer<typeof NotificationEvent>;

Design choice — why discriminated union, not generic { event, payload }. The task signature in task-breakdown.md says notify(event, payload). The contract narrows that to notify(event) where event is a tagged union; the payload fields are tied to the tag via Zod’s discriminator. This gives callers compile-time safety (“task.completed must carry task_id”), whereas a generic payload forces runtime validation at every callsite. The letter of the spec says “event, payload” — we honour the spirit by ensuring the payload travels inside event.

Why no agent.spawned. Explicit task-breakdown.md instruction — agent runtime is deferred per ADR-005. Adding it here would either create a dead code branch or imply a surface we don’t have.

2. notify signature

export interface Notifier {
  /** Send an MCP notification over the open transport. Implementations MUST NOT throw. */
  notification(params: { method: string; params: Record<string, unknown> }): Promise<void>;
}

export interface NotifyOptions {
  /** Injected MCP notifier. When omitted, the mcp channel degrades to "inactive" (stderr log). */
  readonly notifier?: Notifier | undefined;
  /** Env snapshot override for tests. Defaults to `process.env`. */
  readonly env?: NodeJS.ProcessEnv;
  /** `fetch` override for tests. Defaults to global `fetch`. */
  readonly fetchFn?: typeof fetch;
  /** Logger override for tests. Defaults to `console.error`. */
  readonly logger?: (...args: unknown[]) => void;
}

export function notify(
  event: NotificationEvent,
  options?: NotifyOptions,
): Promise<void>;

Return value: Promise<void> that always resolves. Never rejects.

Ordering: channels are dispatched concurrently via Promise.allSettled. notify awaits all three before resolving, but each channel’s failure is captured and logged — it does not affect the others. This keeps the “fire-and-forget” promise (the caller sees one resolved promise) while still letting tests observe channel effects synchronously.

3. Channels

3.1 log — always on

  • Write a single line to stderr via the injected logger (default console.error).
  • Line format: [notify] <event.type> <JSON.stringify(event)>.
  • Never touches process.stdout. Never calls console.log.
  • Never skipped, even if the other channels fail.

3.2 mcp — active only when options.notifier is provided

  • If options.notifier === undefined: log [notify] mcp channel inactive (no notifier injected) to stderr and return. This is a normal operating mode (e.g. library consumers that have no MCP transport).
  • If options.notifier is provided: invoke
    await options.notifier.notification({
      method: 'notifications/colibri/event',
      params: event,  // the Zod-validated event, as-is
    });
    
  • The custom method namespace notifications/colibri/event avoids collision with SDK-standard notification methods (notifications/message, notifications/resources/updated, notifications/tools/list_changed, notifications/progress, etc.).
  • Any error thrown by the notifier is caught, logged as [notify] mcp channel failed: <error> to stderr, and swallowed.

3.3 webhook — active only when COLIBRI_WEBHOOK_URL is set

  • Read the env snapshot: options.env ?? process.env.
  • If COLIBRI_WEBHOOK_URL is absent or empty: skip silently (no log line — a missing env var is a normal operating mode).
  • If set: POST the event as JSON to the URL using the injected fetchFn (default global fetch). Shape:
    await fetchFn(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(event),
    });
    
  • Any error (network, non-2xx response, thrown exception) is caught, logged as [notify] webhook channel failed: <error> to stderr, and swallowed. Phase 0 does NOT retry — the donor’s HMAC signing + exponential backoff (nu-integrations-extraction.md §58-114) is deferred to a future task.
  • A non-2xx HTTP response is treated as an error (parity with how fetch users typically interpret it) and logged. The response body is NOT inspected.

3.4 Cross-channel guarantees

  • Channels run concurrently. The total elapsed time is bounded by the slowest channel (usually webhook if set).
  • No channel is permitted to observe or modify another channel’s payload. Each sees the original event object by value — the implementation freezes the event before dispatch.
  • The log channel always runs first (synchronously composed into the Promise.allSettled tuple so its stderr line lands before any async channel’s log line if they all fail). This is best-effort, not guaranteed — tests do not assert line ordering.

4. Error handling — catch-all invariant

  • Nothing inside notify() may propagate an error to the caller.
  • The outermost level of notify is wrapped in a try/catch that logs [notify] fatal: <error> to stderr. If this catch ever fires, it means a bug — typically an unvalidated event slipping past Zod. Tests for this branch use a deliberately malformed event (z.parse rejects → catch fires → Promise resolves).

5. Failure modes

Failure Behaviour
Zod rejects the event Log [notify] invalid event: <issue> to stderr. Return resolved promise. No channel fires.
logger throws Best-effort — swallowed in the outer catch. Not tested (would require a thrown console.error, unlikely in practice).
notifier.notification rejects Logged, swallowed, Promise.allSettled records a rejected settlement.
fetchFn rejects Logged, swallowed, Promise.allSettled records a rejected settlement.
fetchFn returns non-2xx Logged as a channel failure. Swallowed.
COLIBRI_WEBHOOK_URL is malformed Caught by the Zod schema at boot (z.string().url()) — loadConfig() throws. notify never sees a malformed URL because config.ts is validated before the first notify call.

6. Acceptance criteria → test mapping

# Criterion Test name(s)
1 notify(event) dispatches to configured channels notify → logs every event to stderr · notify → calls notifier when injected · notify → POSTs when COLIBRI_WEBHOOK_URL set
2 Channels log / mcp / webhook (same as above — each channel has its own test)
3 COLIBRI_WEBHOOK_URL enables webhook; AMS_* not read notify → skips webhook when COLIBRI_WEBHOOK_URL unset · config → accepts optional COLIBRI_WEBHOOK_URL · config → still rejects AMS_* prefix (regression — P0.1.4 behaviour preserved)
4 Events: task.completed, merkle.finalized, error.critical (no agent.spawned) event schema → accepts task.completed / merkle.finalized / error.critical · event schema → rejects agent.spawned · event schema → rejects missing discriminator
5 Fire-and-forget: failures do not propagate notify → never rejects when notifier throws · notify → never rejects when fetch throws · notify → never rejects when fetch returns 500 · notify → resolves even on invalid event
6 Each channel gets correct payload per event type notify → log line contains event JSON · notify → notifier receives notifications/colibri/event with event as params · notify → webhook body is JSON.stringify(event) — tested across all three event types

7. Non-goals (Phase 0 exclusions)

Deferred per ADR-005 / task-breakdown.md scope:

  • HMAC-SHA256 body signing (donor mechanic at nu-integrations-extraction.md L75-77).
  • Exponential retry / circuit-breaker (donor mechanic at nu-integrations-extraction.md L82-98).
  • LRU notification history (donor mechanic at nu-integrations-extraction.md L139).
  • Secret redaction (donor mechanic at nu-integrations-extraction.md L139). Phase 0 callers are responsible for not putting secrets into event payloads.
  • Per-channel opt-in flags (Phase 0 = “log always; mcp if notifier; webhook if env”). A richer channel-registry appears later when nobody is only-log.
  • Slack / Discord / Email channels (donor channels — Phase 1+).
  • agent.spawned event (no agent runtime per ADR-005).

8. Review questions (answered)

  • Q1: Why not attach the notifier to ColibriServerContext? A: Coupling direction. ctx is owned by α System Core; adding a ν-specific field there would couple α to ν. The Notifier interface in this module is a minimal structural contract that β/η callers can construct inline from ctx.server.server at the callsite. This keeps the dependency pointing ν → α (ν imports ColibriServerContext if ever needed), never α → ν.

  • Q2: Why Promise.allSettled instead of void fire-and-forget with no await? A: Testing. A pure fire-and-forget (no await) would make assertions racy — the test would need to poll. Awaiting Promise.allSettled lets tests write deterministic assertions while still giving callers the “one promise, always resolves” contract.

  • Q3: Why ISO-8601 at and not numeric epoch? A: The ζ Decision Trail and all existing Phase 0 timestamps are ISO-8601. Cross-surface consistency. JSON-safe. Human-readable in webhook logs.

Step 2 exit criteria

  • Event schema final.
  • notify signature final.
  • Per-channel behaviour specified.
  • Error handling specified.
  • Failure-mode table complete.
  • Acceptance criteria mapped 1:1 to test names.
  • Non-goals enumerated.

Back to top

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

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