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 globalfetchdeclaration varies across@types/nodeversions. Type-checked viaas neveravoids lint issues.jest.spyOn(console, 'error').mockImplementation(() => {})— suppresses log output in tests and lets us assert on call args. Reset inafterEach.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 inafterEach.- Mock
Notifier: plain object withnotification: jest.fn().mockResolvedValue(undefined). No module-level mocks. - Mock
fetch:jest.fn().mockResolvedValue({ ok: true, status: 200 } as Response)— partialResponseis fine because the code only reads.okand.status. - For config tests: use
loadConfig(env)with a hand-built env object; do not setprocess.envglobally (parallelism).
3. Implementation order (Step 4)
src/config.ts— addCOLIBRI_WEBHOOK_URL.src/domains/integrations/notifications.ts— event schema + interfaces first, thendispatchLog/dispatchMcp/dispatchWebhookindependently, thennotifyorchestrator.src/domains/integrations/index.ts— barrel.src/__tests__/domains/integrations/notifications.test.ts— tests, one describe-block at a time.npm test— iterate until green.npm run lint— iterate until clean.
4. Lint / type hazards to watch
z.discriminatedUnionreturns a type that’s fine to export asNotificationEvent, but narrowing inside dispatchers is automatic — don’t useif (event.type === ...)unless you actually need it.typeof fetchin Node 20 is the built-infetch.@types/node@20.17.xdeclaresglobalThis.fetch— should work without extra imports. If TS complains about “fetch is not defined”, add/// <reference lib="dom" />or rely onlib: ["es2022", "DOM"]intsconfig.json(check current config before modifying).Responseis also global in Node 20. Same hazard — if TS can’t find it, fall back tounknownand narrow ((response as { ok?: boolean; status?: number }).ok).NodeJS.ProcessEnv—process.envvalues arestring | undefined. Guard withtypeof 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:
- The 5-step commits are sequential;
git reset --hard HEAD~Nrewinds to the last green state. - PR will not be merged while red — merge is a T0/T1 action.
- 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_finalize→notify({ type: 'merkle.finalized', ... })— deferred. - Wiring error boundary →
notify({ type: 'error.critical', ... })— deferred. - A constructor that builds a
Notifierfrom aColibriServerContext— 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_eventMCP 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.