docs(09): create phase plan (2 plans, 2 waves) — outboxTrigger + drain signal wiring
This commit is contained in:
+11
-2
@@ -136,7 +136,16 @@ Make FamilySync configurable, administrable, and maintainable for real multi-mem
|
|||||||
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
|
- **Create-before-delete under concurrent enqueues** (Pitfall 6): enqueue CREATE before DELETE; do not fire the signal between the two inserts of a move (publish after both inserts / after the transaction commits).
|
||||||
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
|
- Hard constraints: `setInterval` only (no node-cron); single-process by design — **no Redis** for the drain (Redis stays for list SSE); all outbox guarantees (fresh-etag-before-PUT, 412 conflict flow, per-uid exactly-once) unchanged.
|
||||||
|
|
||||||
**Plans**: TBD
|
**Plans**: 2 plans (2 waves)
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
**Wave 1**
|
||||||
|
|
||||||
|
- [ ] 09-01-PLAN.md — TDD: outboxTrigger.ts (zero-dep EventEmitter signal) + scheduleOutboxDrain wrapper / drainRequested trailing-re-drain loop + initOutboxTrigger in outboxWorker.ts; trigger-wiring tests (SC-1, SC-4/D-07, D-05) (Wave 1)
|
||||||
|
|
||||||
|
**Wave 2** *(blocked on Wave 1 completion)*
|
||||||
|
|
||||||
|
- [ ] 09-02-PLAN.md — Four post-commit signalOutboxDrain() publish sites in events.ts (create / edit-as-move-after-transaction / same-cal update / delete) + initOutboxTrigger() startup wiring under isMainModule() in index.ts (Wave 2)
|
||||||
|
|
||||||
### Phase 10: Admin Role & Settings
|
### Phase 10: Admin Role & Settings
|
||||||
|
|
||||||
@@ -308,7 +317,7 @@ Plans:
|
|||||||
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
|
| 6. UX Polish | v1.0 | 6/6 | Complete | 2026-06-10 |
|
||||||
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
| 7. Mobile Test Harness | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
|
| 8. Gitea CI | v1.1 | 4/4 | Complete | 2026-06-11 |
|
||||||
| 9. Faster Write-Back | v1.1 | 0/? | Not started | - |
|
| 9. Faster Write-Back | v1.1 | 0/2 | Not started | - |
|
||||||
| 10. Admin Role & Settings | v1.1 | 0/? | Not started | - |
|
| 10. Admin Role & Settings | v1.1 | 0/? | Not started | - |
|
||||||
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
|
| 11. Per-Event Reminders | v1.1 | 0/? | Not started | - |
|
||||||
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
|
| 12. Initial Setup Wizard | v1.1 | 0/? | Not started | - |
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
phase: 09-faster-write-back
|
||||||
|
plan: 01
|
||||||
|
type: tdd
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/src/lib/outboxTrigger.ts
|
||||||
|
- apps/api/src/broker/outboxWorker.ts
|
||||||
|
- apps/api/tests/broker/outboxWorker.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CAL-15]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Calling signalOutboxDrain() while no drain is in flight invokes runOutboxDrain promptly (no 15s wait)"
|
||||||
|
- "A signal arriving during an in-flight drain triggers exactly one trailing re-drain (D-05)"
|
||||||
|
- "Concurrent scheduleOutboxDrain() calls dispatch each pending row exactly once — no duplicate CalDAV PUT (D-07)"
|
||||||
|
- "runOutboxDrain's body and the isDraining guard are behaviorally unchanged — its existing 15 tests still pass"
|
||||||
|
- "scheduleOutboxDrain catches drain errors so a listener error never escapes the wrapper (D-02)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/src/lib/outboxTrigger.ts"
|
||||||
|
provides: "Zero-dependency in-process EventEmitter drain signal (signalOutboxDrain, onOutboxDrain)"
|
||||||
|
exports: ["signalOutboxDrain", "onOutboxDrain"]
|
||||||
|
contains: "node:events"
|
||||||
|
- path: "apps/api/src/broker/outboxWorker.ts"
|
||||||
|
provides: "scheduleOutboxDrain wrapper + drainRequested flag + initOutboxTrigger subscription"
|
||||||
|
exports: ["scheduleOutboxDrain", "initOutboxTrigger"]
|
||||||
|
contains: "drainRequested"
|
||||||
|
- path: "apps/api/tests/broker/outboxWorker.test.ts"
|
||||||
|
provides: "Trigger-wiring test block (SC-1, SC-4, D-05)"
|
||||||
|
contains: "scheduleOutboxDrain"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/broker/outboxWorker.ts (initOutboxTrigger)"
|
||||||
|
to: "apps/api/src/lib/outboxTrigger.ts (onOutboxDrain)"
|
||||||
|
via: "onOutboxDrain(() => scheduleOutboxDrain())"
|
||||||
|
pattern: "onOutboxDrain\\("
|
||||||
|
- from: "apps/api/src/broker/outboxWorker.ts (scheduleOutboxDrain)"
|
||||||
|
to: "apps/api/src/broker/outboxWorker.ts (runOutboxDrain)"
|
||||||
|
via: "guarded call through isDraining + drainRequested"
|
||||||
|
pattern: "scheduleOutboxDrain"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the event-driven drain signal mechanism: a zero-dependency in-process `EventEmitter` (`outboxTrigger.ts`) and the `scheduleOutboxDrain` scheduler wrapper in `outboxWorker.ts` that funnels signals through the existing `isDraining`-guarded `runOutboxDrain()` with a `drainRequested` trailing-re-drain loop (D-05). Prove SC-1, SC-4, and D-05 with automated trigger-wiring tests (D-09).
|
||||||
|
|
||||||
|
Purpose: Removes the up-to-15s queueing delay before the CalDAV round-trip by signalling the drain on enqueue, while preserving every outbox durability guarantee — `runOutboxDrain`'s internals and the `isDraining` guard stay behaviorally unchanged (CAL-15 / D-02 / D-07).
|
||||||
|
Output: `outboxTrigger.ts` (new), the `scheduleOutboxDrain` + `drainRequested` + `initOutboxTrigger` additions to `outboxWorker.ts`, and a new trigger-wiring describe block in `outboxWorker.test.ts`.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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-VALIDATION.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<artifacts_this_phase_produces>
|
||||||
|
New symbols this phase creates (none exist yet — do not expect them in API-SURFACE.md):
|
||||||
|
|
||||||
|
| Symbol | File | Created By | Kind |
|
||||||
|
|--------|------|-----------|------|
|
||||||
|
| `apps/api/src/lib/outboxTrigger.ts` | (new file) | Plan 01 Task 1 | module |
|
||||||
|
| `signalOutboxDrain` | `apps/api/src/lib/outboxTrigger.ts` | Plan 01 Task 1 | exported function |
|
||||||
|
| `onOutboxDrain` | `apps/api/src/lib/outboxTrigger.ts` | Plan 01 Task 1 | exported function |
|
||||||
|
| `drainRequested` | `apps/api/src/broker/outboxWorker.ts` | Plan 01 Task 3 | module-level flag |
|
||||||
|
| `scheduleOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Plan 01 Task 3 | exported function |
|
||||||
|
| `initOutboxTrigger` | `apps/api/src/broker/outboxWorker.ts` | Plan 01 Task 3 | exported function |
|
||||||
|
|
||||||
|
Plan 02 consumes `signalOutboxDrain` (in `events.ts`) and `initOutboxTrigger` (in `index.ts`).
|
||||||
|
</artifacts_this_phase_produces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Create outboxTrigger.ts zero-dependency EventEmitter signal module</name>
|
||||||
|
<files>apps/api/src/lib/outboxTrigger.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/lib/outboxTrigger.ts (the file being created — confirm it does not yet exist)
|
||||||
|
- apps/api/src/lib/listEmitter.ts (canonical analog: module-level `const emitter = new EventEmitter()`, named publish/subscribe wrappers, zero internal imports)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- signalOutboxDrain() emits the 'drain' event on the module-level emitter (synchronous; fire-and-forget; returns void)
|
||||||
|
- onOutboxDrain(handler) registers handler on 'drain' and returns an unsubscribe function that calls emitter.off('drain', handler)
|
||||||
|
- A handler registered via onOutboxDrain is invoked exactly once per signalOutboxDrain() call
|
||||||
|
- After the returned unsubscribe is called, a subsequent signalOutboxDrain() does NOT invoke the handler
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Create `apps/api/src/lib/outboxTrigger.ts` following the `listEmitter.ts` module-level singleton pattern exactly. Import `EventEmitter` from `node:events`. Declare `const emitter = new EventEmitter();` at module scope. Do NOT call `setMaxListeners` — there is exactly one subscriber (the `initOutboxTrigger` listener), so the default limit of 10 is correct (unlike `listEmitter.ts` which sets 200 for SSE fan-out per its T-04-04 comment). Export `signalOutboxDrain(): void` that calls `emitter.emit('drain')` (fire-and-forget per D-04). Export `onOutboxDrain(handler: () => void): () => void` that calls `emitter.on('drain', handler)` and returns `() => emitter.off('drain', handler)`. This module MUST import nothing from `broker/`, `routes/`, `db/`, or `index.ts` — only `node:events` — to guarantee no circular import (same zero-internal-dependency rule as `listEmitter.ts`). Add a header comment noting: single subscriber, default listener limit fine, signal is fire-and-forget, published only after enqueue commit (D-01/D-03/D-04).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx tsc --noEmit 2>&1 | grep -v '^$' | grep -i 'outboxTrigger' ; test ${PIPESTATUS[1]} -ne 0 || echo "no tsc errors in outboxTrigger"; grep -q "export function signalOutboxDrain" src/lib/outboxTrigger.ts && grep -q "export function onOutboxDrain" src/lib/outboxTrigger.ts && grep -q "node:events" src/lib/outboxTrigger.ts && echo OK</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `apps/api/src/lib/outboxTrigger.ts` exists and exports `signalOutboxDrain` and `onOutboxDrain`
|
||||||
|
- File imports `EventEmitter` from `node:events` and nothing from `broker/`, `routes/`, `db/`, or `index.ts` (`grep -v 'node:events' src/lib/outboxTrigger.ts | grep -E "from '\\.\\./(broker|routes|db)" ` returns nothing)
|
||||||
|
- No `setMaxListeners` call present
|
||||||
|
- `cd apps/api && npx tsc --noEmit` reports no errors referencing `outboxTrigger.ts`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>outboxTrigger.ts exists, exports signalOutboxDrain + onOutboxDrain, depends only on node:events, typechecks clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: RED — add failing trigger-wiring tests for SC-1, D-05, D-07</name>
|
||||||
|
<files>apps/api/tests/broker/outboxWorker.test.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/tests/broker/outboxWorker.test.ts (full file: the `vi.hoisted` + `vi.mock` scaffold, `mockPendingRows`, `makeRow`, `makeResponse`, `wireMockChain`, the `beforeEach` reset block, and the existing describe blocks — extend, do not rewrite)
|
||||||
|
- apps/api/src/lib/outboxTrigger.ts (created in Task 1 — import `signalOutboxDrain` from here)
|
||||||
|
- apps/api/src/broker/outboxWorker.ts (the module under test — `runOutboxDrain` already exported; `scheduleOutboxDrain` will be added in Task 3 and must be imported here in RED so the test fails on the missing export)
|
||||||
|
- apps/api/tests/lib/listEmitter.test.ts (analog for `vi.fn()` handler + unsubscribe test shape)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Test A (SC-1): with one pending row and `createCalendarEvent` mocked to resolve 201, calling `signalOutboxDrain()` then flushing microtasks via `await new Promise(r => setImmediate(r))` results in `createCalendarEvent` called exactly once — with NO `vi.useFakeTimers()` / no 15s advance
|
||||||
|
- Test B (SC-4 / D-05): with `createCalendarEvent` first call held on a manually-resolved promise and two pending rows, calling `signalOutboxDrain()` (drain 1 starts), then `signalOutboxDrain()` twice more while drain 1 is in flight, then releasing drain 1 and flushing, results in exactly one trailing re-drain — `createCalendarEvent` total calls equal (rows in drain 1) + (rows in trailing drain), never a third drain pass
|
||||||
|
- Test C (SC-4 / D-07): with `createCalendarEvent` mocked to a `setTimeout(20ms)`-delayed resolve and one pending row, calling `scheduleOutboxDrain()` twice synchronously then `await vi.runAllTimersAsync()` results in `createCalendarEvent` called exactly once (second concurrent call no-ops via `isDraining`)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Extend `apps/api/tests/broker/outboxWorker.test.ts`. Update the top import to add `scheduleOutboxDrain` to the existing `import { runOutboxDrain, assembleRruleString } from '../../src/broker/outboxWorker.js';` line, and add `import { signalOutboxDrain } from '../../src/lib/outboxTrigger.js';`. Reuse the existing module-scope helpers (`mockPendingRows`, `makeRow`, `makeResponse`, `wireMockChain`) and the `createCalendarEvent` mock from `../../src/broker/write.js` — do NOT redeclare them. Add ONE new describe block after the last existing describe block: `describe('scheduleOutboxDrain — trigger wiring (D-09)', ...)`. Inside, replicate the existing `beforeEach` (`vi.resetAllMocks(); mockPendingRows = []; wireMockChain();`). Because `isDraining`/`drainRequested` are module-level flags that persist across tests in the same module instance, end each test that leaves a drain potentially in flight by awaiting two `setImmediate` flushes so the module flags settle before the next test. Write Test A, Test B, Test C per the `<behavior>` block. For Test A and Test B use `await new Promise<void>(resolve => setImmediate(resolve))` for microtask flushing (the signal→drain path is microtask-driven, no timers — per RESEARCH Q5); for Test B use `vi.mocked(createCalendarEvent).mockImplementationOnce(async () => { await firstDone; return makeResponse(201); }).mockResolvedValue(makeResponse(201))` with a manually-captured `resolveFirst`. For Test C use `vi.useFakeTimers()` + `vi.runAllTimersAsync()` and restore real timers in that test's cleanup. Assert exact call counts per `<behavior>`. This task is RED: it imports `scheduleOutboxDrain` which does not exist yet, so the file fails to resolve / the new tests fail. Do NOT implement `scheduleOutboxDrain` in this task. Do NOT touch the existing 15 tests.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts 2>&1 | tail -20; echo "EXPECT: RED — new 'trigger wiring' tests fail or module fails to resolve scheduleOutboxDrain"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- The new `describe('scheduleOutboxDrain — trigger wiring (D-09)', ...)` block exists with three tests (Test A SC-1, Test B D-05, Test C D-07)
|
||||||
|
- The test file imports `scheduleOutboxDrain` from `../../src/broker/outboxWorker.js` and `signalOutboxDrain` from `../../src/lib/outboxTrigger.js`
|
||||||
|
- Running `npx vitest run tests/broker/outboxWorker.test.ts` shows the new trigger-wiring tests RED (fail) — confirming the test exercises behavior not yet implemented
|
||||||
|
- The 15 pre-existing tests are unmodified (no edits inside their describe blocks)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Three new trigger-wiring tests written and failing (RED); existing 15 tests untouched.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: GREEN — add scheduleOutboxDrain wrapper, drainRequested flag, initOutboxTrigger; switch setInterval to the wrapper</name>
|
||||||
|
<files>apps/api/src/broker/outboxWorker.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/broker/outboxWorker.ts (full file — confirm: `let isDraining = false;` at L159; `runOutboxDrain` guard `if (isDraining) return; isDraining = true;` at L602–605; `finally { isDraining = false; }` at L783–785; `startOutboxWorker` setInterval calling `runOutboxDrain().catch(...)` at L796–802; the file-header export-rationale comment at L19–22)
|
||||||
|
- apps/api/src/lib/outboxTrigger.ts (import `onOutboxDrain` from here for `initOutboxTrigger`)
|
||||||
|
- apps/api/tests/broker/outboxWorker.test.ts (the RED tests from Task 2 that this task turns GREEN)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Modify `apps/api/src/broker/outboxWorker.ts`. (1) Add `import { onOutboxDrain } from '../lib/outboxTrigger.js';` with the other imports. (2) Immediately after `let isDraining = false;` (L159) add `let drainRequested = false;`. (3) Add and EXPORT a new `scheduleOutboxDrain(): void` function (place it between the `isDraining`/`drainRequested` declarations and `runOutboxDrain`): if `isDraining` is true, set `drainRequested = true` and return; otherwise call `runOutboxDrain()` and chain `.catch((err: unknown) => console.error('[outboxWorker] Unhandled runOutboxDrain error:', err))` then `.finally(() => { if (drainRequested) { drainRequested = false; scheduleOutboxDrain(); } })`. The `drainRequested = false` reset MUST come BEFORE the recursive `scheduleOutboxDrain()` call (Pitfall 3 — resetting after would allow an infinite loop). (4) Do NOT modify `runOutboxDrain`'s body or its `isDraining` guard/`finally` in any way (preserves the existing 15 tests and D-02/D-07). (5) Add and EXPORT `initOutboxTrigger(): void` that calls `onOutboxDrain(() => scheduleOutboxDrain())` — placed alongside `startOutboxWorker` in the scheduler section. (6) Update `startOutboxWorker`'s `setInterval` body to call `scheduleOutboxDrain()` instead of `runOutboxDrain().catch(...)` — the per-call `.catch` is now absorbed into `scheduleOutboxDrain`, so the interval body becomes a bare `scheduleOutboxDrain();` call (keep the 15 * 1000 interval exactly per D-08). (7) Extend the file-header export-rationale comment (L19–22) with a line: `scheduleOutboxDrain is exported for unit testing; it wraps runOutboxDrain with the isDraining guard + drainRequested trailing-re-drain loop (D-05).` This task is GREEN: the Task 2 tests must now pass.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts 2>&1 | tail -8; npx tsc --noEmit 2>&1 | grep -E 'outboxWorker|outboxTrigger' || echo "tsc clean for outbox files"; grep -q "export function scheduleOutboxDrain" src/broker/outboxWorker.ts && grep -q "export function initOutboxTrigger" src/broker/outboxWorker.ts && grep -q "let drainRequested = false" src/broker/outboxWorker.ts && echo OK</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `outboxWorker.ts` exports `scheduleOutboxDrain` and `initOutboxTrigger`, and declares `let drainRequested = false`
|
||||||
|
- `runOutboxDrain`'s guard line `if (isDraining) return;`, `isDraining = true;`, and `finally { isDraining = false; }` are byte-for-byte unchanged from before this plan (the trailing re-drain lives only in `scheduleOutboxDrain`, never inside `runOutboxDrain`)
|
||||||
|
- In `scheduleOutboxDrain`'s `.finally`, `drainRequested = false` precedes the recursive `scheduleOutboxDrain()` call (Pitfall 3)
|
||||||
|
- `startOutboxWorker`'s `setInterval` body calls `scheduleOutboxDrain()` and keeps the `15 * 1000` interval (D-08)
|
||||||
|
- `initOutboxTrigger` calls `onOutboxDrain(() => scheduleOutboxDrain())`
|
||||||
|
- `npx vitest run tests/broker/outboxWorker.test.ts` is GREEN (all trigger-wiring tests pass AND the 15 pre-existing tests still pass)
|
||||||
|
- `cd apps/api && npx tsc --noEmit` reports no errors in `outboxWorker.ts` / `outboxTrigger.ts`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>scheduleOutboxDrain + drainRequested + initOutboxTrigger added; setInterval routes through the wrapper; runOutboxDrain internals unchanged; full outboxWorker.test.ts suite GREEN; tsc clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| route handler → in-process EventEmitter | A committed enqueue publishes a `'drain'` signal in-process. No network surface, no new auth boundary, no new user input crosses here — the signal carries no payload. |
|
||||||
|
| EventEmitter listener → runOutboxDrain | The single subscriber funnels the signal into the existing `isDraining`-guarded drain. Existing trust boundaries (CalDAV credential decryption, Fastmail I/O) are unchanged downstream. |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-09-01 | Denial of Service | `scheduleOutboxDrain` trailing-re-drain loop | mitigate | `drainRequested` is a boolean that collapses all mid-drain signals into exactly one trailing drain (D-05/D-06); it is reset BEFORE the recursive call (Pitfall 3), so a burst can never produce an unbounded re-drain chain against Fastmail rate limits. Test B (D-05) asserts exactly one trailing drain. |
|
||||||
|
| T-09-02 | Denial of Service | listener registration in test process | mitigate | `onOutboxDrain` is invoked only from `initOutboxTrigger`, which (in Plan 02) is called only under `isMainModule()` in `index.ts`. Tests import `runOutboxDrain`/`scheduleOutboxDrain` directly and never register a listener — no open-handle leak preventing process exit. Single subscriber → default 10-listener limit suffices, no `setMaxListeners`. |
|
||||||
|
| T-09-03 | Denial of Service | error in drain escaping the wrapper | mitigate | `scheduleOutboxDrain` wraps `runOutboxDrain()` in `.catch(...)` that logs and swallows, matching the existing setInterval error-swallowing pattern (D-02). A thrown drain error cannot crash the process or escape into the EventEmitter `emit` call frame. |
|
||||||
|
| T-09-04 | Tampering | signal-induced reentrancy bypassing isDraining | accept→mitigate | The signal NEVER calls `runOutboxDrain()` directly; it always goes through `scheduleOutboxDrain`, which checks `isDraining` before dispatch (D-02). Test C (D-07) asserts concurrent `scheduleOutboxDrain` calls dispatch each row exactly once. No new tampering vector — the guard is reused verbatim. |
|
||||||
|
|
||||||
|
No `npm`/`pip`/`cargo` install in this plan (zero-dependency, `node:events` only) — no T-09-SC supply-chain threat applies. RESEARCH Package Legitimacy Audit confirms no new external packages.
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` — full broker suite GREEN (15 existing + 3 new trigger-wiring tests)
|
||||||
|
- `cd apps/api && npx tsc --noEmit` — no type errors (Vitest passes while tsc fails; typecheck is mandatory per [[vitest-passes-tsc-fails]])
|
||||||
|
- `grep -q "export function scheduleOutboxDrain" apps/api/src/broker/outboxWorker.ts` — wrapper exported
|
||||||
|
- `runOutboxDrain` guard/finally unchanged: diff the `runOutboxDrain` body region against the pre-plan version — no edits inside it
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- outboxTrigger.ts created (zero-dependency, node:events only, single subscriber, no setMaxListeners)
|
||||||
|
- scheduleOutboxDrain implements the isDraining-guarded drain with a drainRequested trailing-re-drain loop (D-05), errors caught (D-02), exactly-once preserved (D-07)
|
||||||
|
- runOutboxDrain internals + isDraining guard behaviorally unchanged (existing 15 tests still pass)
|
||||||
|
- 15s setInterval routes through scheduleOutboxDrain, interval length unchanged (D-08)
|
||||||
|
- Trigger-wiring tests for SC-1, D-05, D-07 pass (D-09)
|
||||||
|
- tsc --noEmit clean
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/09-faster-write-back/09-01-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
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\\(\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
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`.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.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
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<artifacts_this_phase_produces>
|
||||||
|
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"]`.
|
||||||
|
</artifacts_this_phase_produces>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Publish signalOutboxDrain() after each enqueue commit in events.ts (4 sites)</name>
|
||||||
|
<files>apps/api/src/routes/events.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Four post-commit signal sites added; edit-as-move signal fires after the transaction commits; no awaited signal, no inline CalDAV; tsc clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire initOutboxTrigger() at startup under isMainModule()</name>
|
||||||
|
<files>apps/api/src/index.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- 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`)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
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.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>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"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `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`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>initOutboxTrigger() wired after startOutboxWorker() inside the isMainModule() guard; import updated; tsc clean.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## 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).
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `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)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- 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
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/phases/09-faster-write-back/09-02-SUMMARY.md` when done.
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user