---
phase: 09-faster-write-back
plan: 02
type: execute
wave: 2
depends_on: ["09-01"]
files_modified:
- apps/api/src/routes/events.ts
- apps/api/src/index.ts
autonomous: true
requirements: [CAL-15]
must_haves:
truths:
- "Creating an event publishes the drain signal after the outbox insert commits, then returns 202 (SC-1, SC-2)"
- "Editing an event (same-calendar update) publishes the signal after the insert commits, then returns 202"
- "Edit-as-move publishes the signal AFTER the db.transaction resolves — never between the DELETE and CREATE inserts (SC-3 / D-03)"
- "Deleting an event publishes the signal after the insert commits, then returns 202"
- "No route handler makes an inline CalDAV call; signalOutboxDrain() is fire-and-forget, never awaited (SC-2 / D-04)"
- "The drain-signal listener is wired at startup only under isMainModule(), after startOutboxWorker (SC-5)"
artifacts:
- path: "apps/api/src/routes/events.ts"
provides: "Four post-commit signalOutboxDrain() publish sites (create, edit-as-move, same-cal update, delete)"
contains: "signalOutboxDrain"
- path: "apps/api/src/index.ts"
provides: "initOutboxTrigger() wiring under isMainModule(), after startOutboxWorker()"
contains: "initOutboxTrigger"
key_links:
- from: "apps/api/src/routes/events.ts"
to: "apps/api/src/lib/outboxTrigger.ts (signalOutboxDrain)"
via: "import + call after each enqueue commit"
pattern: "signalOutboxDrain\\(\\)"
- from: "apps/api/src/index.ts (isMainModule block)"
to: "apps/api/src/broker/outboxWorker.ts (initOutboxTrigger)"
via: "initOutboxTrigger() after startOutboxWorker()"
pattern: "initOutboxTrigger\\(\\)"
---
Wire the drain signal into the live request and startup paths: publish `signalOutboxDrain()` after each of the four enqueue commits in `events.ts` (create, edit-as-move, same-calendar update, delete) and subscribe the drain listener at startup in `index.ts` via `initOutboxTrigger()` under `isMainModule()`.
Purpose: Connects the Plan 01 signal mechanism to real writes (so edits land in ~1-2s — SC-1) and to process startup (so the listener is active in production but never in the test process — SC-5), while keeping the optimistic-202 / no-inline-CalDAV route contract intact (SC-2 / D-04) and preserving create-before-delete ordering for moves (SC-3 / D-03).
Output: Four publish-site edits in `events.ts`, the `initOutboxTrigger()` startup call + import update in `index.ts`.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-faster-write-back/09-CONTEXT.md
@.planning/phases/09-faster-write-back/09-RESEARCH.md
@.planning/phases/09-faster-write-back/09-PATTERNS.md
@.planning/phases/09-faster-write-back/09-01-SUMMARY.md
This plan creates NO new symbols — it consumes symbols created by Plan 01:
| Consumed Symbol | Source File (Plan 01) | Consumed In |
|-----------------|----------------------|-------------|
| `signalOutboxDrain` | `apps/api/src/lib/outboxTrigger.ts` | `apps/api/src/routes/events.ts` (4 call sites) |
| `initOutboxTrigger` | `apps/api/src/broker/outboxWorker.ts` | `apps/api/src/index.ts` (startup wiring) |
Both symbols MUST already exist (Plan 01, Wave 1) before this plan runs — enforced by `depends_on: ["09-01"]`.
Task 1: Publish signalOutboxDrain() after each enqueue commit in events.ts (4 sites)
apps/api/src/routes/events.ts
- apps/api/src/routes/events.ts (full file — confirm the four enqueue sites and their commit boundaries: Site 1 POST /create `await db.insert(calendarOutbox)` at ~L300 then `return c.json({ uid }, 202)` at L309; Site 2 PATCH /:uid/edit edit-as-move `await db.transaction(...)` at L408–432 then `return c.json({ uid: newUid }, 202)` at L432; Site 3 PATCH /:uid/edit same-calendar update `await db.insert(calendarOutbox)` at ~L436 then `return c.json({ uid }, 202)` at L447; Site 4 DELETE /:uid `await db.insert(calendarOutbox)` at ~L511 then `return c.json({ uid }, 202)` at L521. All four sites are inside existing try/catch blocks.)
- apps/api/src/lib/outboxTrigger.ts (Plan 01 — import `signalOutboxDrain` from here)
Modify `apps/api/src/routes/events.ts`. (1) Add `import { signalOutboxDrain } from '../lib/outboxTrigger.js';` with the existing imports. (2) Insert a fire-and-forget `signalOutboxDrain();` call (NOT awaited — D-04) immediately AFTER the awaited enqueue and BEFORE the `return c.json(..., 202)` at each of the four sites:
- Site 1 (POST /create): after `await db.insert(calendarOutbox).values({...})` (~L300–308), before `return c.json({ uid }, 202)` (L309).
- Site 2 (PATCH /:uid/edit, edit-as-move): after `await db.transaction(async (tx) => {...})` RESOLVES (L408–432) — the call goes on the line AFTER the `await db.transaction(...)` statement, NOT inside the transaction callback. This is the critical placement (D-03 / Pitfall 6 / Pitfall 1): publishing inside the callback would fire before the DELETE+CREATE rows are durably committed. Place it before `return c.json({ uid: newUid }, 202)` (L432).
- Site 3 (PATCH /:uid/edit, same-calendar update): after `await db.insert(calendarOutbox).values({ operation: 'update', ... })` (~L436–445), before `return c.json({ uid }, 202)` (L447).
- Site 4 (DELETE /:uid): after `await db.insert(calendarOutbox).values({ operation: 'delete', ... })` (~L511–519), before `return c.json({ uid }, 202)` (L521).
Do NOT change any route's status code, response body, validation, ownership checks, or the no-inline-CalDAV behavior — the only change is adding the post-commit signal call at each site. Do NOT await the signal. Do NOT add a signal inside the `db.transaction` callback or in any `catch` block.
cd apps/api && grep -c "signalOutboxDrain()" src/routes/events.ts | grep -qx 4 && echo "4 signal sites" && ! grep -Pzo "db\.transaction\(async[^)]*\)\s*=>\s*\{[^}]*signalOutboxDrain" src/routes/events.ts && echo "no signal inside transaction callback" && npx tsc --noEmit 2>&1 | grep -E 'events\.ts' || echo "tsc clean for events.ts"
- `events.ts` imports `signalOutboxDrain` from `'../lib/outboxTrigger.js'`
- Exactly four `signalOutboxDrain()` calls exist (`grep -c "signalOutboxDrain()" src/routes/events.ts` == 4), one per enqueue site
- At Site 2 the `signalOutboxDrain()` call appears AFTER the `await db.transaction(...)` statement, not inside its callback (no `signalOutboxDrain` between the two `tx.insert` calls)
- No `signalOutboxDrain()` is awaited (`grep -n "await signalOutboxDrain" src/routes/events.ts` returns nothing — D-04 fire-and-forget)
- No route handler gained an inline CalDAV call; status codes and bodies unchanged (still 202 with `{ uid }` / `{ uid: newUid }`)
- `cd apps/api && npx tsc --noEmit` reports no errors in `events.ts`
Four post-commit signal sites added; edit-as-move signal fires after the transaction commits; no awaited signal, no inline CalDAV; tsc clean.
Task 2: Wire initOutboxTrigger() at startup under isMainModule()
apps/api/src/index.ts
- apps/api/src/index.ts (full file — confirm: import `import { startOutboxWorker } from './broker/outboxWorker.js';` at L16; `isMainModule()` function at L100–107; the `if (isMainModule())` startup block at L112 with `startBrokerPoller()` (L136), `startOutboxWorker()` (L138), `startReminderScheduler()` (L141))
- apps/api/src/broker/outboxWorker.ts (Plan 01 — `initOutboxTrigger` is exported from this module alongside `startOutboxWorker`)
Modify `apps/api/src/index.ts`. (1) Update the existing import at L16 from `import { startOutboxWorker } from './broker/outboxWorker.js';` to also import `initOutboxTrigger`: `import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';`. (2) Inside the existing `if (isMainModule())` block, add `initOutboxTrigger();` on the line immediately AFTER `startOutboxWorker();` (L138) — so the fallback interval is registered before the signal listener subscribes (logical ordering; both are synchronous). Add an inline comment: `// subscribe drain signal listener (D-01)`. Do NOT add the call outside the `isMainModule()` guard — the listener MUST be gated so tests that import `app` never register it (Pitfall 4 — listener/timer leakage in tests). Do NOT modify `isMainModule()` itself, `startBrokerPoller`, `startReminderScheduler`, or the `serve(...)` call.
cd apps/api && grep -q "import { startOutboxWorker, initOutboxTrigger }" src/index.ts && grep -q "initOutboxTrigger();" src/index.ts && awk '/if \(isMainModule\(\)\)/{f=1} f&&/initOutboxTrigger\(\)/{print "inside guard"; exit}' src/index.ts | grep -q "inside guard" && echo "guarded" && npx tsc --noEmit 2>&1 | grep -E 'index\.ts' || echo "tsc clean for index.ts"
- `index.ts` imports `initOutboxTrigger` from `'./broker/outboxWorker.js'` (same import statement as `startOutboxWorker`)
- `initOutboxTrigger();` appears inside the `if (isMainModule())` block, on the line after `startOutboxWorker();`
- No `initOutboxTrigger()` call exists outside the `isMainModule()` guard (Pitfall 4)
- `isMainModule()`, `startBrokerPoller`, `startReminderScheduler`, and `serve(...)` are unchanged
- `cd apps/api && npx tsc --noEmit` reports no errors in `index.ts`
initOutboxTrigger() wired after startOutboxWorker() inside the isMainModule() guard; import updated; tsc clean.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → route handler | Unchanged — same Zod validation, ownership checks, and optimistic-202 contract. This plan adds no new input, no new endpoint, no new status path. |
| route handler → in-process EventEmitter | A committed enqueue calls `signalOutboxDrain()` in-process. No payload, no network, fire-and-forget. |
| process startup → EventEmitter listener | `initOutboxTrigger()` registers the single drain listener only under `isMainModule()`. The test process never crosses this boundary. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-05 | Tampering | edit-as-move signal placement | mitigate | `signalOutboxDrain()` is published AFTER `await db.transaction(...)` resolves, never inside the callback (D-03 / Pitfall 1) — so the DELETE+CREATE rows are durably committed before any drain can observe them; create-before-delete ordering (SC-3) is never raced. Verify step asserts no signal inside the transaction callback. |
| T-09-06 | Denial of Service | listener registered in test process | mitigate | `initOutboxTrigger()` is called only inside `if (isMainModule())` (Pitfall 4) — tests importing `app` never register the listener, so no spurious cross-test drains and no open handle preventing process exit. Verify step asserts the call is inside the guard. |
| T-09-07 | Denial of Service | fire-and-forget signal blocking the request | accept | `signalOutboxDrain()` is synchronous and emits to a single in-process listener that returns immediately (the listener calls `scheduleOutboxDrain` which is itself fire-and-forget); it cannot block the 202 response. No CalDAV I/O happens inline (SC-2 / D-04). Low risk, no mitigation needed beyond not awaiting the call. |
No `npm`/`pip`/`cargo` install in this plan — no supply-chain (T-09-SC) threat. No new external package (RESEARCH Package Legitimacy Audit: none introduced).
- `cd apps/api && npx vitest run` — FULL suite GREEN (the existing events.ts route tests confirm 202 + no inline CalDAV still hold after the signal additions; the Plan 01 trigger-wiring tests still pass)
- `cd apps/api && npx tsc --noEmit` — no type errors in `events.ts` or `index.ts` (mandatory — Vitest passes while tsc fails per [[vitest-passes-tsc-fails]])
- `grep -c "signalOutboxDrain()" apps/api/src/routes/events.ts` == 4 — all four publish sites present
- `initOutboxTrigger()` is inside the `isMainModule()` block (Pitfall 4)
- Four post-commit `signalOutboxDrain()` publish sites in events.ts (create, edit-as-move-after-transaction, same-cal update, delete) — SC-1
- Route handlers still return optimistic 202 with no inline CalDAV; signal is fire-and-forget — SC-2 / D-04
- Edit-as-move signal fires after the transaction commits, preserving create-before-delete ordering — SC-3 / D-03
- initOutboxTrigger() wired under isMainModule() after startOutboxWorker(); fallback interval still active — SC-5
- Full Vitest suite GREEN + tsc --noEmit clean