P0.9.3 — ν Notification Channels — Execution Packet

Step 3 of the 5-step chain. File-by-file plan, test inventory, rollback strategy. Gates Step 4.

1. File plan

1.1 src/config.ts — 1-line addition

Add COLIBRI_WEBHOOK_URL: z.string().url().optional(), inside the Zod schema (between COLIBRI_LOG_LEVEL and COLIBRI_STARTUP_TIMEOUT_MS keeps it alphabetical among the COLIBRI_* group; current order is DB_PATH / LOG_LEVEL / STARTUP_TIMEOUT_MS so it can land between LOG_LEVEL and STARTUP_TIMEOUT_MS).

No other change to config.ts. The existing assertNoDonorNamespace already rejects AMS_* — preserved.

1.2 src/domains/integrations/notifications.ts — new file

Top-level layout:

/** JSDoc header: purpose, donor references, donor-bug mitigations. */

import { z } from 'zod';

// --- event schema --------------------------------------------------------
const TaskCompleted    = z.object({ type: z.literal('task.completed'),   task_id:    z.string().min(1), at: z.string().min(1) });
const MerkleFinalized  = z.object({ type: z.literal('merkle.finalized'), session_id: z.string().min(1), root: z.string().min(1), at: z.string().min(1) });
const ErrorCritical    = z.object({ type: z.literal('error.critical'),   message:    z.string().min(1), at: z.string().min(1) });
export const notificationEventSchema = z.discriminatedUnion('type', [TaskCompleted, MerkleFinalized, ErrorCritical]);
export type NotificationEvent = z.infer<typeof notificationEventSchema>;

// --- Notifier interface --------------------------------------------------
export interface Notifier {
  notification(params: { method: string; params: Record<string, unknown> }): Promise<void>;
}

// --- notify options ------------------------------------------------------
export interface NotifyOptions {
  readonly notifier?: Notifier | undefined;
  readonly env?: NodeJS.ProcessEnv;
  readonly fetchFn?: typeof fetch;
  readonly logger?: (...args: unknown[]) => void;
}

// --- notify --------------------------------------------------------------
export async function notify(event: NotificationEvent, options: NotifyOptions = {}): Promise<void> { ... }

// --- internals (exported for tests) -------------------------------------
export const COLIBRI_NOTIFICATION_METHOD = 'notifications/colibri/event';
export async function dispatchLog(event: NotificationEvent, logger: (...args: unknown[]) => void): Promise<void> { ... }
export async function dispatchMcp(event: NotificationEvent, notifier: Notifier | undefined, logger: (...args: unknown[]) => void): Promise<void> { ... }
export async function dispatchWebhook(event: NotificationEvent, env: NodeJS.ProcessEnv, fetchFn: typeof fetch, logger: (...args: unknown[]) => void): Promise<void> { ... }

notify body — parse → freeze → Promise.allSettled three dispatchers → return. Top-level try/catch for the “unreachable” branch.

1.3 src/domains/integrations/index.ts — barrel

export * from './notifications.js';

One line. Future ν tasks (MCP broker, Anthropic client) will add more exports.

1.4 src/__tests__/domains/integrations/notifications.test.ts — tests

Jest ESM (--experimental-vm-modules). No module mocks. jest.spyOn for global.fetch / console.error only when asserting on default behavior — most tests inject the mocks via NotifyOptions.

Test structure:

describe('notificationEventSchema', () => {
  test('accepts task.completed payload');
  test('accepts merkle.finalized payload');
  test('accepts error.critical payload');
  test('rejects agent.spawned (not a Phase 0 event)');
  test('rejects missing discriminator');
  test('rejects task.completed missing task_id');
});

describe('notify — log channel', () => {
  test('writes single stderr line for each event');
  test('JSON-serializes the event onto the log line');
  test('never touches stdout'); // spy on process.stdout.write to confirm zero calls
});

describe('notify — mcp channel', () => {
  test('calls notifier.notification with notifications/colibri/event');
  test('passes the event as the notification params');
  test('degrades to stderr log when notifier is omitted');
  test('swallows notifier throw and logs to stderr');
});

describe('notify — webhook channel', () => {
  test('skips POST when COLIBRI_WEBHOOK_URL unset');
  test('POSTs application/json to COLIBRI_WEBHOOK_URL when set');
  test('body is JSON.stringify(event)');
  test('logs and swallows network error');
  test('logs and swallows non-2xx response');
});

describe('notify — fire-and-forget invariant', () => {
  test('resolves even when notifier rejects');
  test('resolves even when fetch rejects');
  test('resolves even when fetch returns 500');
  test('resolves (and logs) when event is invalid');
});

describe('notify — payload-per-event-type coverage', () => {
  test.each(['task.completed','merkle.finalized','error.critical'])(
    'each channel receives correct payload for %s', ...
  );
});

describe('config — COLIBRI_WEBHOOK_URL', () => {
  test('loadConfig accepts a valid URL');
  test('loadConfig rejects a non-URL string');
  test('loadConfig leaves the field undefined when unset');
  test('loadConfig still rejects AMS_* (regression)');
});

~22 tests total. All should run under 1s.

2. Test technique notes

  • jest.spyOn(global, 'fetch' as never) — cast required because the global fetch declaration varies across @types/node versions. Type-checked via as never avoids lint issues.
  • jest.spyOn(console, 'error').mockImplementation(() => {}) — suppresses log output in tests and lets us assert on call args. Reset in afterEach.
  • jest.spyOn(process.stdout, 'write').mockImplementation(() => true) — used only in the “never touches stdout” test to verify the log channel does not write there. Reset in afterEach.
  • Mock Notifier: plain object with notification: jest.fn().mockResolvedValue(undefined). No module-level mocks.
  • Mock fetch: jest.fn().mockResolvedValue({ ok: true, status: 200 } as Response) — partial Response is fine because the code only reads .ok and .status.
  • For config tests: use loadConfig(env) with a hand-built env object; do not set process.env globally (parallelism).

3. Implementation order (Step 4)

  1. src/config.ts — add COLIBRI_WEBHOOK_URL.
  2. src/domains/integrations/notifications.ts — event schema + interfaces first, then dispatchLog / dispatchMcp / dispatchWebhook independently, then notify orchestrator.
  3. src/domains/integrations/index.ts — barrel.
  4. src/__tests__/domains/integrations/notifications.test.ts — tests, one describe-block at a time.
  5. npm test — iterate until green.
  6. npm run lint — iterate until clean.

4. Lint / type hazards to watch

  • z.discriminatedUnion returns a type that’s fine to export as NotificationEvent, but narrowing inside dispatchers is automatic — don’t use if (event.type === ...) unless you actually need it.
  • typeof fetch in Node 20 is the built-in fetch. @types/node@20.17.x declares globalThis.fetch — should work without extra imports. If TS complains about “fetch is not defined”, add /// <reference lib="dom" /> or rely on lib: ["es2022", "DOM"] in tsconfig.json (check current config before modifying).
  • Response is also global in Node 20. Same hazard — if TS can’t find it, fall back to unknown and narrow ((response as { ok?: boolean; status?: number }).ok).
  • NodeJS.ProcessEnvprocess.env values are string | undefined. Guard with typeof url === 'string' && url.length > 0.
  • Return types — ensure every exported function has an explicit return type (project lint rule per other modules).

5. Edge cases

Case Expected
notify called with options = {} log channel runs; mcp channel logs “inactive”; webhook skips if env unset
notify called twice in rapid succession Both resolve independently. No internal state shared.
Event with extra properties Zod strict mode is NOT enabled on the schema — extras are stripped by default. Payload sent on webhook/mcp has ONLY the declared fields (Zod parse output).
COLIBRI_WEBHOOK_URL set to empty string z.string().url() rejects empty. loadConfig() would throw at boot. notify never sees it.
COLIBRI_WEBHOOK_URL set to "http://..." POST fires.
fetch global unavailable (Node < 18) Out of scope — package.json engines requires Node ≥ 20.

6. Rollback strategy

If any step fails CI or breaks npm test:

  1. The 5-step commits are sequential; git reset --hard HEAD~N rewinds to the last green state.
  2. PR will not be merged while red — merge is a T0/T1 action.
  3. Worst case, docs/* remain and the impl is reverted: the 4 docs describe the design cleanly and a follow-up round can re-land impl. Docs alone are harmless (they sit under CANON zone with explicit Phase 0 reality stamps).

7. Out-of-scope (not this PR)

  • Wiring β task completion → notify({ type: 'task.completed', ... }) — deferred. This task ships the library only.
  • Wiring η merkle_finalizenotify({ type: 'merkle.finalized', ... }) — deferred.
  • Wiring error boundary → notify({ type: 'error.critical', ... }) — deferred.
  • A constructor that builds a Notifier from a ColibriServerContext — one-line helper callers can write inline: { notification: (p) => ctx.server.server.notification(p) }. Documenting it inline at the callsite is fine; no need to export a helper in P0.9.3.
  • Registering a notify_event MCP tool — also deferred. The Phase 0 tool surface target is 60-80; this PR does not add a tool.

Step 3 exit criteria

  • File plan final.
  • Test plan final with counts + describe structure.
  • Implementation order defined.
  • Type/lint hazards called out.
  • Edge cases enumerated.
  • Rollback documented.
  • Out-of-scope items listed so future rounds don’t duplicate work.

Back to top

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

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