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(defaultconsole.error). - Line format:
[notify] <event.type> <JSON.stringify(event)>. - Never touches
process.stdout. Never callsconsole.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.notifieris provided: invokeawait options.notifier.notification({ method: 'notifications/colibri/event', params: event, // the Zod-validated event, as-is }); - The custom method namespace
notifications/colibri/eventavoids 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_URLis 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 globalfetch). 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
fetchusers 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
eventobject by value — the implementation freezes the event before dispatch. - The
logchannel always runs first (synchronously composed into thePromise.allSettledtuple 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
notifyis 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.parserejects → 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.spawnedevent (no agent runtime per ADR-005).
8. Review questions (answered)
-
Q1: Why not attach the notifier to
ColibriServerContext? A: Coupling direction.ctxis owned by α System Core; adding a ν-specific field there would couple α to ν. TheNotifierinterface in this module is a minimal structural contract that β/η callers can construct inline fromctx.server.serverat the callsite. This keeps the dependency pointing ν → α (ν importsColibriServerContextif ever needed), never α → ν. -
Q2: Why
Promise.allSettledinstead ofvoidfire-and-forget with no await? A: Testing. A pure fire-and-forget (no await) would make assertions racy — the test would need to poll. AwaitingPromise.allSettledlets tests write deterministic assertions while still giving callers the “one promise, always resolves” contract. -
Q3: Why ISO-8601
atand 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.
notifysignature final.- Per-channel behaviour specified.
- Error handling specified.
- Failure-mode table complete.
- Acceptance criteria mapped 1:1 to test names.
- Non-goals enumerated.