diff --git a/.planning/phases/09-faster-write-back/09-RESEARCH.md b/.planning/phases/09-faster-write-back/09-RESEARCH.md
new file mode 100644
index 0000000..1f33007
--- /dev/null
+++ b/.planning/phases/09-faster-write-back/09-RESEARCH.md
@@ -0,0 +1,731 @@
+# Phase 9: Faster Write-Back - Research
+
+**Researched:** 2026-06-12
+**Domain:** In-process event-driven outbox drain signal (Node.js EventEmitter, TypeScript)
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-01:** New artifact is a single zero-dependency in-process `EventEmitter` at `apps/api/src/lib/outboxTrigger.ts`. No new npm dependency.
+- **D-02:** The signal funnels through the **existing** `isDraining`-guarded `runOutboxDrain()` path via a `drainRequested` flag. The trigger must NEVER call `runOutboxDrain()` directly in a way that bypasses the `isDraining` guard or escapes the error-caught wrapper (Pitfall 5 — no double-drain).
+- **D-03:** The signal is published **after** the enqueue transaction commits — never between the two inserts of an edit-as-move (Pitfall 6). For a move, publish once after both CREATE and DELETE rows are committed, so create-before-delete ordering is never raced by the signal.
+- **D-04:** Route handlers still return optimistic 202 immediately and make no inline CalDAV call; the signal is fire-and-forget.
+- **D-05:** **Guarantee the last edit in a burst.** When a signal arrives while a drain is already in flight, set `drainRequested` and re-run the drain exactly once after the current one finishes (drain-again-if-requested loop).
+- **D-06:** No debounce/coalesce window and no rejection of mid-drain signals.
+- **D-07:** Exactly-once per uid preserved — trailing re-drain reuses the same `pending AND next_attempt_at <= NOW()` selection and `isDraining` guard.
+- **D-08:** Keep the 15s `setInterval` fallback exactly as-is. Do not lengthen it.
+- **D-09:** Prove SC-1 with an automated integration test on the trigger wiring (Vitest). Also cover trailing-re-drain and no-double-PUT overlap cases.
+- **D-10:** No operator-stopwatch human checkpoint required.
+
+### Claude's Discretion
+- Exact shape of the `drainRequested`/re-drain loop (where the flag lives, whether the loop sits inside `runOutboxDrain`'s `finally` or in the scheduler wrapper).
+- Whether the EventEmitter is a module singleton vs. a tiny custom signal object — either is fine; zero-dep is the only hard constraint.
+- Test file placement follows the existing convention (API tests live under `apps/api/tests/`, never `src/`).
+
+### Deferred Ideas (OUT OF SCOPE)
+- Multi-process / multi-replica outbox drain — durable DB row-claim instead of the in-memory `isDraining` guard. Not this phase.
+- Debounce/coalesce knob for burst write-back — rejected for now (D-06).
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CAL-15 | A created, edited, or deleted event reaches Fastmail within ~2 seconds (event-driven outbox drain) instead of up to ~15s, while preserving the optimistic-202 accept and all outbox durability guarantees (create-before-delete ordering, drain concurrency guard, fresh-etag-before-PUT, per-uid exactly-once). | Signal mechanism design (Q1–Q4), post-commit publish points (Q3), test wiring (Q5). |
+
+
+---
+
+## Summary
+
+Phase 9 adds a single new module — `apps/api/src/lib/outboxTrigger.ts` — that is a module-level `EventEmitter` singleton following the exact pattern already established by `apps/api/src/lib/listEmitter.ts`. The outbox drain loop in `outboxWorker.ts` already has all the durability machinery needed; the only required change is adding a `drainRequested` flag and a trailing-re-drain loop around the `isDraining` guard, then wiring a listener so an enqueue signal triggers a prompt drain.
+
+The two concrete changes to existing files are: (1) `outboxWorker.ts` gains the `drainRequested` flag and a wrapper function (`scheduleOutboxDrain`) that implements the drain-again-if-requested loop and gets called from both the EventEmitter listener and the 15s `setInterval`; (2) `events.ts` gains three call sites that publish the signal after each commit. Startup wiring in `index.ts` follows the existing `startBrokerPoller`/`startOutboxWorker` pattern.
+
+The test strategy builds directly on the existing `outboxWorker.test.ts` mock infrastructure. No new npm packages are needed.
+
+**Primary recommendation:** Implement the `drainRequested` loop as a thin scheduler wrapper (`scheduleOutboxDrain`) in `outboxWorker.ts` rather than inside `runOutboxDrain`'s `finally`, so `runOutboxDrain` itself remains testable in isolation and its existing tests are unchanged.
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Event-driven drain signal | API / Backend (in-process) | — | Single-process; no Redis for drain; in-memory EventEmitter is correct tier |
+| Outbox row enqueue | API / Backend (route handler) | — | Existing responsibility; this phase only adds the post-commit signal publish |
+| Drain execution + CalDAV I/O | API / Backend (broker) | — | `runOutboxDrain` already owns all CalDAV dispatch; signal funnels into it |
+| Fallback interval timing | API / Backend (broker) | — | 15s `setInterval` in `startOutboxWorker`; unchanged |
+| Test wiring validation | API / Backend (Vitest) | — | Unit/integration tests under `apps/api/tests/`; no UI involved |
+
+---
+
+## Standard Stack
+
+### Core
+
+No new packages required for this phase. [VERIFIED: CLAUDE.md + codebase read]
+
+| Module | Purpose | Status |
+|--------|---------|--------|
+| `node:events` (built-in) | `EventEmitter` for the drain signal | Already used in `listEmitter.ts` |
+| `vitest` | Test framework for trigger wiring tests | Already installed and configured |
+
+### Supporting
+
+No supporting additions needed.
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Module-level `EventEmitter` singleton | Custom minimal signal object (`{ on, emit, off }`) | Either satisfies D-01. The `EventEmitter` approach is idiomatic Node.js, matches `listEmitter.ts` exactly, and requires zero extra code. A custom object is slightly lighter but offers no practical advantage here. |
+| Trailing-re-drain in scheduler wrapper | Trailing-re-drain inside `runOutboxDrain`'s `finally` | Wrapper approach keeps `runOutboxDrain` a pure, independently testable drain cycle with no self-rescheduling logic. The `finally` approach couples drain + re-scheduling into one function, complicates existing test isolation. |
+
+**Installation:** No new packages required.
+
+---
+
+## Package Legitimacy Audit
+
+No new external packages are introduced in this phase. The only new code uses `node:events` (Node.js built-in, zero-dep constraint from D-01). [VERIFIED: codebase read]
+
+**Packages removed due to SLOP verdict:** none
+**Packages flagged as suspicious SUS:** none
+
+---
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+POST /api/events/create
+PATCH /api/events/:uid/edit ──► db.insert (/ db.transaction)
+DELETE /api/events/:uid │
+ commit │ (post-commit only)
+ ▼
+ outboxTrigger.emit('drain')
+ │
+ ┌────────────────┘
+ │ listener in outboxWorker.ts
+ ▼
+ scheduleOutboxDrain() ◄── 15s setInterval also calls this
+ │
+ drainRequested already set?
+ Yes → drainRequested=true (D-05: trailing flag)
+ No → if (!isDraining) runOutboxDrain()
+ │
+ finally: drainRequested?
+ Yes → clear flag, call runOutboxDrain() again (once)
+ │
+ CalDAV PUT/DELETE to Fastmail
+ │
+ Mark outbox row 'done'
+ │
+ triggerTargetedResync()
+```
+
+### Recommended Project Structure
+
+No structural changes to existing directories. One new file added:
+
+```
+apps/api/src/
+└── lib/
+ ├── listEmitter.ts # existing — established pattern to follow
+ └── outboxTrigger.ts # NEW: module-level drain signal emitter
+
+apps/api/tests/
+└── broker/
+ └── outboxWorker.test.ts # extend with trigger wiring tests (D-09)
+```
+
+---
+
+## Key Open Questions — Answers for the Planner
+
+### Q1: Where does `drainRequested` and the trailing-re-drain loop belong?
+
+**Answer: In a new `scheduleOutboxDrain()` wrapper in `outboxWorker.ts`, NOT inside `runOutboxDrain`'s `finally`.** [VERIFIED: codebase read — outboxWorker.ts]
+
+**Rationale from the existing code:**
+
+`runOutboxDrain()` (L602–L786) is a self-contained drain cycle:
+- `if (isDraining) return;` guard at the top (L604)
+- `isDraining = true;` (L605)
+- `try { ... } finally { isDraining = false; }` (L783–L785)
+
+The `finally` block currently contains only `isDraining = false`. Adding a re-drain call there would mean `runOutboxDrain` recurses into itself via a flag, making the function non-idempotent and complicating the existing 15 test cases that call `runOutboxDrain()` directly.
+
+**Concrete shape:**
+
+```typescript
+// Module-level flag (joins isDraining)
+let drainRequested = false;
+
+/**
+ * Scheduler entrypoint called by both the EventEmitter listener and the 15s setInterval.
+ * Implements the drain-again-if-requested loop (D-05) so the last edit in a burst
+ * is drained promptly without double-draining the same row (D-02 / D-07).
+ *
+ * Fire-and-forget: callers do not await this.
+ */
+function scheduleOutboxDrain(): void {
+ if (isDraining) {
+ // A drain is already in flight — set flag so it re-runs when done (D-05)
+ drainRequested = true;
+ return;
+ }
+ // Kick off a drain cycle; on completion, check if another was requested
+ runOutboxDrain()
+ .catch((err: unknown) => {
+ console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
+ })
+ .finally(() => {
+ if (drainRequested) {
+ drainRequested = false;
+ scheduleOutboxDrain(); // exactly one trailing re-drain (D-05)
+ }
+ });
+}
+```
+
+Key properties:
+- `isDraining` is set/cleared inside `runOutboxDrain` exactly as today — no change to `runOutboxDrain`.
+- When a signal arrives while `isDraining=true`, `drainRequested=true` is set. After the in-flight drain's `finally` clears `isDraining=false`, the `.finally()` on the outer Promise re-calls `scheduleOutboxDrain()`, which sees `isDraining=false` and starts a fresh drain.
+- The recursive `scheduleOutboxDrain()` call in `.finally()` is safe because `drainRequested` is cleared before the call, so a second re-entrant signal during the trailing drain sets it again to true and produces exactly one more follow-up — not an unbounded chain.
+- The 15s `setInterval` already calls `runOutboxDrain()` directly (L797). It should be updated to call `scheduleOutboxDrain()` instead, so the fallback path also benefits from the trailing-re-drain guarantee and doesn't double-drain if a signal-triggered drain is in progress at the 15s mark.
+
+**Updated `startOutboxWorker`:**
+
+```typescript
+export function startOutboxWorker(): void {
+ setInterval(() => {
+ scheduleOutboxDrain();
+ }, 15 * 1000);
+}
+```
+
+### Q2: Module/singleton shape for `outboxTrigger.ts`
+
+**Answer: Module-level `EventEmitter` singleton, following `listEmitter.ts` verbatim.** [VERIFIED: codebase read — listEmitter.ts]
+
+`listEmitter.ts` establishes the exact pattern already used in this codebase:
+
+```typescript
+// apps/api/src/lib/outboxTrigger.ts
+
+import { EventEmitter } from 'node:events';
+
+const emitter = new EventEmitter();
+// No persistent listeners — max 1 (the outboxWorker subscriber). Default limit is 10.
+
+/**
+ * Signal the outbox drain that a new row was enqueued.
+ * Called from routes/events.ts after each enqueue commit (D-03).
+ * Fire-and-forget — never awaited.
+ */
+export function signalOutboxDrain(): void {
+ emitter.emit('drain');
+}
+
+/**
+ * Subscribe to drain signals. Returns an unsubscribe function.
+ * Called once from outboxWorker.ts (via initOutboxTrigger in index.ts).
+ */
+export function onOutboxDrain(handler: () => void): () => void {
+ emitter.on('drain', handler);
+ return () => emitter.off('drain', handler);
+}
+```
+
+**Circular-import safety:** `outboxTrigger.ts` imports only `node:events`. It has no imports from `broker/`, `routes/`, `db/`, or `index.ts`. Both consumers import from it:
+- `routes/events.ts` imports `signalOutboxDrain` (publish side)
+- `broker/outboxWorker.ts` imports `onOutboxDrain` via a wiring call from `index.ts` (subscribe side)
+
+There is no circular import risk because `outboxTrigger.ts` depends on nothing in the project.
+
+**Timer/listener leakage in Vitest:** The `EventEmitter` itself has no timers. Listeners are added only under `isMainModule()` in `index.ts`. Test files import `runOutboxDrain` from `outboxWorker.ts` directly — they never trigger `isMainModule()` initialization — so no listener is registered in the test process. This is the same pattern used for `startBrokerPoller`, `startOutboxWorker`, and `startReminderScheduler` (all gated by `isMainModule()` at `index.ts` L112).
+
+### Q3: Exact post-commit publish points in `events.ts`
+
+**Answer: Three publish sites, all after the `await db.insert()`/`await db.transaction()` call that commits the outbox row(s).** [VERIFIED: codebase read — events.ts]
+
+The current enqueue sites and their commit boundaries:
+
+**Site 1 — POST /api/events/create (L300–L308):**
+```typescript
+await db.insert(calendarOutbox).values({ ... });
+// ← publish here (after db.insert resolves)
+return c.json({ uid }, 202);
+```
+The `db.insert` is a single auto-committed statement (no explicit transaction). Signal fires immediately after the awaited insert.
+
+**Site 2 — PATCH /api/events/:uid/edit, same-calendar update (L436–L445):**
+```typescript
+await db.insert(calendarOutbox).values({ operation: 'update', ... });
+// ← publish here
+return c.json({ uid }, 202);
+```
+Same pattern — single auto-committed insert.
+
+**Site 3 — PATCH /api/events/:uid/edit, edit-as-move (L408–L432):**
+```typescript
+await db.transaction(async (tx) => {
+ await tx.insert(calendarOutbox).values({ operation: 'delete', ... });
+ await tx.insert(calendarOutbox).values({ operation: 'create', ... });
+});
+// ← publish HERE — after the transaction commits (D-03)
+// NOT inside the transaction callback (that is before commit, not after)
+return c.json({ uid: newUid }, 202);
+```
+This is the critical one: the signal must fire after the `await db.transaction(...)` resolves, not inside the callback. Both rows are committed atomically when `db.transaction` resolves. Publishing inside the callback would fire the signal before the DELETE row is durably written (violates D-03 / Pitfall 6 — create-before-delete ordering could be raced).
+
+**Site 4 — DELETE /api/events/:uid (L511–L519):**
+```typescript
+await db.insert(calendarOutbox).values({ operation: 'delete', ... });
+// ← publish here
+return c.json({ uid }, 202);
+```
+
+All four sites are inside a `try/catch` block. The signal call should go immediately after the successful `await`, before the `return c.json(...)`. The signal is fire-and-forget — no `await` on `signalOutboxDrain()`.
+
+**Pattern (same for all sites):**
+```typescript
+await db.insert(calendarOutbox).values({ ... });
+signalOutboxDrain(); // fire-and-forget, D-04
+return c.json({ uid }, 202);
+```
+
+### Q4: Wiring in `index.ts` under `isMainModule()`
+
+**Answer: Add `initOutboxTrigger()` export to `outboxWorker.ts` that subscribes the listener, and call it from `index.ts` alongside `startOutboxWorker()`.** [VERIFIED: codebase read — index.ts L112–L146]
+
+The existing startup block at `index.ts` L136–L141:
+```typescript
+if (isMainModule()) {
+ // ... VAPID setup ...
+ startBrokerPoller();
+ startOutboxWorker();
+ startReminderScheduler();
+ // serve(...)
+}
+```
+
+Add the subscription wiring as an exported function in `outboxWorker.ts`:
+```typescript
+import { onOutboxDrain } from '../lib/outboxTrigger.js';
+
+export function initOutboxTrigger(): void {
+ onOutboxDrain(() => scheduleOutboxDrain());
+}
+```
+
+Then in `index.ts`:
+```typescript
+import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
+
+if (isMainModule()) {
+ // ...
+ startOutboxWorker();
+ initOutboxTrigger(); // wire drain signal after startOutboxWorker (D-01)
+ // ...
+}
+```
+
+Order matters: `startOutboxWorker()` should be called before `initOutboxTrigger()` so the fallback interval is running before the signal listener is active. In practice both are synchronous registrations so order within the same tick is fine, but matching the logical dependency is cleaner.
+
+### Q5: Vitest test strategy for trigger wiring (D-09)
+
+**Answer: Extend `tests/broker/outboxWorker.test.ts` with a new describe block for trigger wiring. Use the existing mock infrastructure verbatim; control async timing with `vi.useFakeTimers()` or manual promise resolution.** [VERIFIED: codebase read — tests/broker/outboxWorker.test.ts]
+
+**Test placement:** `apps/api/tests/broker/outboxWorker.test.ts` — extend the existing file, or a sibling `tests/lib/outboxTrigger.test.ts` if isolation is preferred. The existing `outboxWorker.test.ts` already has the full DB/write/sync mock scaffold. Extending it avoids re-wiring the mock chain.
+
+**What to test (D-09 + CAL-15 Success Criteria):**
+
+**Test A — SC-1: enqueue signal → drain invoked promptly**
+
+Strategy: spy on `runOutboxDrain` (or on `createCalendarEvent` from `write.js`) and assert it is called after `signalOutboxDrain()` without any timer advancement.
+
+```typescript
+it('signal after enqueue invokes runOutboxDrain without waiting 15s', async () => {
+ const { createCalendarEvent } = await import('../../src/broker/write.js');
+ vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201));
+ mockPendingRows = [makeRow()];
+
+ signalOutboxDrain();
+
+ // Let microtasks / promise chain flush
+ await new Promise(resolve => setImmediate(resolve));
+
+ expect(createCalendarEvent).toHaveBeenCalledTimes(1);
+ // No timer advance needed — signal fires synchronously
+});
+```
+
+Note: `signalOutboxDrain` calls `emitter.emit('drain')` synchronously. The listener calls `scheduleOutboxDrain()`. If `isDraining` is false, `runOutboxDrain()` is called immediately (but it returns a Promise). The `await new Promise(resolve => setImmediate(resolve))` flushes the microtask queue so the async drain completes.
+
+**Test B — SC-4 / D-05: signal during in-flight drain → exactly one trailing re-drain**
+
+Strategy: make `createCalendarEvent` slow (using a manually resolved promise), fire the signal twice, assert `runOutboxDrain` ran exactly twice (not three or more times).
+
+```typescript
+it('D-05: signal arriving mid-drain triggers exactly one trailing re-drain', async () => {
+ let resolveFirst: () => void;
+ const firstDone = new Promise(res => { resolveFirst = res; });
+
+ const { createCalendarEvent } = await import('../../src/broker/write.js');
+ vi.mocked(createCalendarEvent)
+ .mockImplementationOnce(async () => { await firstDone; return makeResponse(201); })
+ .mockResolvedValue(makeResponse(201));
+
+ mockPendingRows = [makeRow({ id: 1 }), makeRow({ id: 2 })];
+
+ // Start drain 1 (in-flight)
+ signalOutboxDrain();
+
+ // While drain 1 is in flight, fire two more signals
+ signalOutboxDrain();
+ signalOutboxDrain();
+
+ // Release drain 1
+ resolveFirst!();
+
+ // Wait for trailing drain to complete
+ await new Promise(resolve => setImmediate(resolve));
+ await new Promise(resolve => setImmediate(resolve));
+
+ // createCalendarEvent called for rows in drain 1 + exactly one trailing drain
+ // Not called 3+ times (coalesced signals → one trailing run)
+ expect(vi.mocked(createCalendarEvent).mock.calls.length).toBeLessThanOrEqual(
+ mockPendingRows.length * 2
+ );
+});
+```
+
+**Test C — SC-4 / D-07: 15s fallback + signal do not double-PUT the same row**
+
+Strategy: rely on the existing CR-05 test which already verifies `isDraining` prevents concurrent `runOutboxDrain` calls. The scheduler wrapper shares the same `isDraining` guard, so the existing test covers this. Add a new test that calls `scheduleOutboxDrain()` twice concurrently and asserts `createCalendarEvent` is called exactly once.
+
+```typescript
+it('D-07: concurrent scheduleOutboxDrain calls dispatch each row exactly once', async () => {
+ const { createCalendarEvent } = await import('../../src/broker/write.js');
+ vi.mocked(createCalendarEvent).mockImplementation(
+ () => new Promise(resolve => setTimeout(() => resolve(makeResponse(201)), 20))
+ );
+ mockPendingRows = [makeRow({ id: 1 })];
+
+ scheduleOutboxDrain();
+ scheduleOutboxDrain();
+ await vi.runAllTimersAsync(); // flush setTimeout(20ms)
+
+ expect(createCalendarEvent).toHaveBeenCalledTimes(1);
+});
+```
+
+**Exporting `scheduleOutboxDrain` for tests:** `scheduleOutboxDrain` is an internal scheduler function. For testability, export it from `outboxWorker.ts` similarly to how `runOutboxDrain` is already exported (`runOutboxDrain is exported for unit testing` — L19). The same rationale applies.
+
+**Deterministic async control:** Use `setImmediate`-based microtask flushing (not `vi.useFakeTimers()`) for signal→drain tests, since the signal path is entirely microtask-driven (no `setTimeout`/`setInterval`). Use `vi.useFakeTimers()` only for tests that need to advance the 15s fallback interval.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Drain signal mechanism | Custom pub/sub, Redis channel, custom promise queue | `node:events` `EventEmitter` | Built-in, zero-dep, already used in `listEmitter.ts`. The drain is single-process — no cross-process messaging needed. |
+| Concurrency control for drain | Custom mutex, semaphore, DB lock | Existing `isDraining` module-level flag | Already covers the single-process deployment; adding a second mechanism creates dual-source-of-truth bugs. |
+| Burst coalescing | Debounce timer, queue with max-depth | `drainRequested` boolean flag | For a two-user household the burst is never more than 2-3 concurrent edits. A boolean flag is correct and sufficient (D-06). |
+| Timer-free async test control | `sleep()`, `await new Promise(r => setTimeout(r, 100))` (flaky) | `setImmediate` flush for microtasks; `vi.runAllTimersAsync()` for fake-timer tests | Deterministic, zero wall-clock dependency |
+
+**Key insight:** The entire phase is wiring — connecting an already-correct drain function to an already-proven event mechanism. The only implementation risk is in the flag placement and the post-commit publish ordering, both of which are fully determined by reading the existing code.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Publishing the signal inside the `db.transaction()` callback (edit-as-move)
+
+**What goes wrong:** The DELETE row is not yet durably written when the callback runs. The signal fires, the drain starts immediately, and it sees only the CREATE row. The DELETE row appears in the next drain cycle with sibling status already 'done', so it proceeds — but CREATE happened before DELETE as required. However, if the signal fires between the two `tx.insert` calls (impossible in the current sequential async code, but could happen if restructured), only one of the two rows would be in the DB.
+
+**Why it happens:** The `db.transaction(async (tx) => { ... })` callback executes inside the transaction boundary. `await db.transaction(...)` resolves when the transaction commits.
+
+**How to avoid:** Publish `signalOutboxDrain()` after `await db.transaction(...)` resolves, not inside the callback. The current code structure makes this natural — the `await db.transaction(...)` is on one line; the signal call goes on the next line after it.
+
+**Warning signs:** If the test for "edit-as-move creates-before-delete" starts failing intermittently, check where the signal publish is placed relative to the transaction boundary.
+
+### Pitfall 2: Calling `runOutboxDrain()` directly from the signal listener (bypassing `isDraining`)
+
+**What goes wrong:** If the signal listener calls `runOutboxDrain()` directly instead of going through `scheduleOutboxDrain()`, and the 15s `setInterval` fires at the same moment, both calls enter `runOutboxDrain` simultaneously. The second call returns immediately (isDraining guard), but there is a race on the `isDraining = true` assignment vs. the guard check in a microtask boundary.
+
+**Why it happens:** EventEmitter listeners run synchronously in the same tick as `emit`. If `isDraining` is false when the listener fires, the listener starts a drain. If the setInterval fires in the same tick (impossible in practice for a 15s interval, but possible in tests using fake timers), a second concurrent drain starts.
+
+**How to avoid:** Both the listener and the setInterval call `scheduleOutboxDrain()`, not `runOutboxDrain()` directly. `scheduleOutboxDrain` checks `isDraining` before calling `runOutboxDrain`, and sets `drainRequested` if already draining. This is exactly D-02.
+
+### Pitfall 3: `drainRequested` flag not reset before the trailing drain call
+
+**What goes wrong:** If `drainRequested` is set to `false` after the trailing `scheduleOutboxDrain()` call in the `.finally()`, not before, a new signal arriving between the flag reset and the trailing drain call sets `drainRequested=true` again. The trailing drain then completes, and `.finally()` sees `drainRequested=true` again — triggering an infinite loop.
+
+**How to avoid:** In the `.finally()` callback: check `drainRequested`, set `drainRequested = false`, then call `scheduleOutboxDrain()`. The re-check inside `scheduleOutboxDrain` handles any new signals that arrived after the reset.
+
+```typescript
+.finally(() => {
+ if (drainRequested) {
+ drainRequested = false; // reset FIRST
+ scheduleOutboxDrain(); // then recurse — if new signal arrived, drainRequested=true again
+ }
+});
+```
+
+### Pitfall 4: Registering the EventEmitter listener at module import time (timer/listener leakage in tests)
+
+**What goes wrong:** If `onOutboxDrain(handler)` is called at the top level of `outboxWorker.ts` (outside a function), it registers a listener when the module is imported. Vitest imports `outboxWorker.ts` to test `runOutboxDrain`. The listener is registered, and any `signalOutboxDrain()` call in a test unexpectedly triggers the handler, causing spurious drain calls across test isolation boundaries.
+
+**How to avoid:** Register the listener only inside `initOutboxTrigger()`, which is called only from `index.ts` under `isMainModule()`. This matches the `startBrokerPoller`/`startOutboxWorker`/`startReminderScheduler` pattern exactly.
+
+### Pitfall 5: Using `setMaxListeners` too low on the drain emitter
+
+**What goes wrong:** Not a real risk here (the drain emitter has exactly one subscriber — `initOutboxTrigger`). But if the default limit of 10 is somehow hit (e.g. tests that call `initOutboxTrigger` without cleanup), Node.js emits a `MaxListenersExceededWarning`.
+
+**How to avoid:** Since there is exactly one subscriber, the default limit of 10 is fine. No `setMaxListeners` call needed (unlike `listEmitter.ts` which sets 200 for SSE fan-out).
+
+---
+
+## Code Examples
+
+### Pattern 1: Module-level EventEmitter singleton (from `listEmitter.ts`) [VERIFIED: codebase read]
+
+```typescript
+// apps/api/src/lib/outboxTrigger.ts
+import { EventEmitter } from 'node:events';
+
+const emitter = new EventEmitter();
+// No setMaxListeners needed — single subscriber
+
+export function signalOutboxDrain(): void {
+ emitter.emit('drain');
+}
+
+export function onOutboxDrain(handler: () => void): () => void {
+ emitter.on('drain', handler);
+ return () => emitter.off('drain', handler);
+}
+```
+
+### Pattern 2: `scheduleOutboxDrain` wrapper with trailing-re-drain loop [VERIFIED: codebase read + logic derivation from D-05]
+
+```typescript
+// In outboxWorker.ts, after `let isDraining = false;`
+
+let drainRequested = false;
+
+function scheduleOutboxDrain(): void {
+ if (isDraining) {
+ drainRequested = true;
+ return;
+ }
+ runOutboxDrain()
+ .catch((err: unknown) => {
+ console.error('[outboxWorker] Unhandled runOutboxDrain error:', err);
+ })
+ .finally(() => {
+ if (drainRequested) {
+ drainRequested = false;
+ scheduleOutboxDrain();
+ }
+ });
+}
+
+// Export for test access
+export { scheduleOutboxDrain };
+```
+
+### Pattern 3: Post-commit signal publish in route handler [VERIFIED: codebase read — events.ts]
+
+```typescript
+// In the create handler, after the db.insert:
+await db.insert(calendarOutbox).values({ ... });
+signalOutboxDrain(); // fire-and-forget (D-04)
+return c.json({ uid }, 202);
+
+// In the edit-as-move handler, after the transaction:
+await db.transaction(async (tx) => {
+ await tx.insert(calendarOutbox).values({ operation: 'delete', ... });
+ await tx.insert(calendarOutbox).values({ operation: 'create', ... });
+});
+signalOutboxDrain(); // after transaction commits — both rows durable (D-03)
+return c.json({ uid: newUid }, 202);
+```
+
+### Pattern 4: Startup wiring in `index.ts` [VERIFIED: codebase read — index.ts L112–L146]
+
+```typescript
+// import addition at top of index.ts:
+import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
+
+// In the isMainModule() block (after startOutboxWorker):
+startOutboxWorker();
+initOutboxTrigger(); // subscribe the signal listener
+```
+
+### Pattern 5: Vitest async flush without fake timers [ASSUMED — standard Node.js async behavior]
+
+```typescript
+// Flush microtasks + immediate queue (for testing signal→drain without timers)
+await new Promise(resolve => setImmediate(resolve));
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| node-cron for scheduled jobs | `setInterval` only | Prior to v1.0 (node-cron 4.2.1 silently skipped ticks) | Hard constraint: do not reintroduce node-cron |
+| Top-level background worker startup | Gated by `isMainModule()` | Phase 03 (WR-04 fix) | Hard constraint: all listeners/timers must be inside `isMainModule()` |
+
+**Deprecated/outdated:**
+- `node-cron` for this project: silently skips scheduled executions in the long-running server process. `setInterval` only.
+
+---
+
+## Runtime State Inventory
+
+Step 2.5: SKIPPED (not a rename/refactor/migration phase — greenfield addition of one module and wiring changes to three existing files).
+
+---
+
+## Environment Availability
+
+Step 2.6: No external tool dependencies beyond the existing Node.js + pnpm stack already confirmed operational. All changes are in-process TypeScript.
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js `node:events` | `outboxTrigger.ts` | Always — built-in | Same as runtime (22 LTS) | — |
+| Vitest | Test suite | Already installed | Per `package.json` | — |
+| MariaDB (for integration tests) | `apps/api/tests/` | Confirmed (existing CI) | Dev compose | — |
+
+---
+
+## Validation Architecture
+
+Nyquist validation is enabled (config `workflow.nyquist_validation` not set to false).
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | Vitest (vite-native) |
+| Config file | `apps/api/vitest.config.ts` |
+| Quick run command | `pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts` |
+| Full suite command | `pnpm --filter @familysync/api exec vitest run` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Success Criterion | Behavior | Test Type | Automated Command | File Exists? |
+|--------|------------------|----------|-----------|-------------------|-------------|
+| CAL-15 / SC-1 | Change reaches Fastmail in ~1-2s | Signal after enqueue triggers `runOutboxDrain` without interval wait | unit (trigger wiring) | `pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts` | Extend existing |
+| CAL-15 / SC-2 | Route handler returns 202 immediately, no inline CalDAV | Existing passing tests cover 202 response; signal is fire-and-forget | unit (existing) | Same | Already exists |
+| CAL-15 / SC-3 | Edit-as-move create-before-delete preserved | Existing D-04 / CR-04 tests cover this; signal fires after transaction so ordering is not disturbed | unit (existing) | Same | Already exists |
+| CAL-15 / SC-4 | No duplicate PUT when signal and 15s interval overlap | Concurrent `scheduleOutboxDrain` calls invoke `createCalendarEvent` exactly once | unit (trigger wiring) | Same | Extend existing |
+| CAL-15 / SC-4 | Trailing re-drain under burst (D-05) | Signal mid-drain → exactly one follow-up drain | unit (trigger wiring) | Same | Extend existing |
+| CAL-15 / SC-5 | 15s fallback still runs | Existing `startOutboxWorker` setInterval unchanged; `scheduleOutboxDrain` called from interval | unit (scheduler) | Same | Extend existing |
+
+### Sampling Rate
+- **Per task commit:** `pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts`
+- **Per wave merge:** `pnpm --filter @familysync/api exec vitest run`
+- **Phase gate:** Full suite green before `/gsd-verify-work`
+
+### Wave 0 Gaps
+
+- [ ] New describe block in `tests/broker/outboxWorker.test.ts` — covers SC-1 (trigger wiring), SC-4 (no-double-PUT), D-05 (trailing re-drain)
+- [ ] `outboxTrigger.ts` must export `signalOutboxDrain` and `onOutboxDrain` before the trigger wiring tests can import them
+- [ ] `scheduleOutboxDrain` must be exported from `outboxWorker.ts` for direct test invocation
+
+---
+
+## Security Domain
+
+`security_enforcement` is enabled (not set to false in config).
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | No | Not touched — auth is unchanged |
+| V3 Session Management | No | Not touched |
+| V4 Access Control | No | Not touched — ownership checks in `events.ts` are unchanged |
+| V5 Input Validation | No | Not touched — Zod validation at enqueue is unchanged; this phase only adds a post-enqueue signal |
+| V6 Cryptography | No | Not touched — credential decryption in `outboxWorker.ts` is unchanged |
+
+### Known Threat Patterns for this phase
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Signal amplification (unbounded re-drain loop) | DoS (against Fastmail rate limits) | `drainRequested` boolean collapses all mid-drain signals into exactly one trailing drain (D-05 / D-06) |
+| Listener memory leak (test isolation) | DoS (open handles preventing process exit) | Listener registered only under `isMainModule()` — never in test process (Q4 answer above) |
+
+No new network surface, no new auth paths, no new input validation vectors introduced by this phase.
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `setImmediate` flush is sufficient to observe the signal→drain path in tests without fake timers | Q5 / Validation Architecture | Test may need `await Promise.resolve()` or an extra tick; low risk, easily corrected in the test code |
+| A2 | Exporting `scheduleOutboxDrain` from `outboxWorker.ts` for test access follows the established `runOutboxDrain` export precedent without breaking encapsulation | Q5 | Low risk — already documented in `outboxWorker.ts` L19 as the intended pattern |
+
+All other claims in this document are verified against the codebase.
+
+---
+
+## Open Questions
+
+1. **`scheduleOutboxDrain` — internal or re-exported from `startOutboxWorker`?**
+ - What we know: `runOutboxDrain` is already exported for testing (L19 comment). `scheduleOutboxDrain` needs the same treatment.
+ - What's unclear: whether to export it as a named export at the module level or wrap it inside `startOutboxWorker` and expose via a returned object.
+ - Recommendation: export it as a plain named export (same as `runOutboxDrain`) for simplicity and test symmetry. The existing pattern is to export thin named functions.
+
+2. **`initOutboxTrigger` — in `outboxWorker.ts` or a separate `outboxTriggerInit.ts`?**
+ - What we know: `startBrokerPoller`, `startOutboxWorker`, `startReminderScheduler` are all exported from their respective broker modules and called from `index.ts`.
+ - Recommendation: keep `initOutboxTrigger` in `outboxWorker.ts` alongside `startOutboxWorker` — they are functionally related (both are outbox startup concerns) and co-locating them means `index.ts` only needs one additional import name from the same module.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- `apps/api/src/broker/outboxWorker.ts` — Full read; confirmed `isDraining` flag structure, `runOutboxDrain` control flow, `startOutboxWorker` setInterval, `finally` block shape, existing export rationale.
+- `apps/api/src/routes/events.ts` — Full read; confirmed exact locations of the three enqueue sites (create L300–308, edit-same-cal L436–445, edit-as-move L408–432, delete L511–519), transaction boundaries.
+- `apps/api/src/index.ts` — Full read; confirmed `isMainModule()` guard, startup wiring block L112–L146.
+- `apps/api/src/lib/listEmitter.ts` — Full read; confirmed EventEmitter singleton pattern, `publish`/`subscribe`/`unsubscribe` API shape.
+- `apps/api/tests/broker/outboxWorker.test.ts` — Full read; confirmed mock infrastructure, existing test coverage, `vi.hoisted` + `vi.mock` pattern.
+- `apps/api/vitest.config.ts` — Read; confirmed `fileParallelism: false`, `environment: 'node'`, no setup file override.
+- `.planning/phases/09-faster-write-back/09-CONTEXT.md` — Full read; locked decisions D-01…D-10.
+- `.planning/REQUIREMENTS.md` — Read; CAL-15 requirement text confirmed.
+- `.planning/config.json` — Read; `nyquist_validation` not set to false → Validation Architecture section required; `security_enforcement` defaults to enabled.
+
+### Secondary (MEDIUM confidence)
+- `apps/api/tests/lib/listEmitter.test.ts` — Read for test pattern; confirms `vi.fn()` + `unsub()` test shape usable for trigger tests.
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — no new packages; all patterns verified in codebase
+- Architecture: HIGH — drain control flow fully read and analyzed
+- Pitfalls: HIGH — derived from actual code paths, not assumptions
+- Test strategy: HIGH — based on existing test file infrastructure; one ASSUMED item on `setImmediate` flush
+
+**Research date:** 2026-06-12
+**Valid until:** 2026-07-12 (stable internal code — only invalidated by changes to `outboxWorker.ts` or `events.ts`)