---
phase: 03-event-write-back-pwa-install
plan: 10
type: tdd
wave: 2
depends_on: ["03-09"]
gap_closure: true
autonomous: true
requirements: [CAL-04, CAL-05, CAL-06, CAL-07]
files_modified:
  - apps/api/src/broker/outboxWorker.ts
  - apps/api/src/broker/vevent.ts
  - apps/api/tests/broker/outboxWorker.test.ts
  - apps/api/tests/broker/vevent.test.ts
must_haves:
  truths:
    - "The worker parses the stored form JSON and PUTs a real VCALENDAR string built by buildVeventString — never raw {\"title\":...} JSON"
    - "The PUT body begins with 'BEGIN:VCALENDAR' for both create and update operations"
    - "A single-day all-day event produces DTEND = DTSTART + 1 day (RFC 5545 exclusive end), proven by a DIRECT buildVeventString unit test against the D-13 contract"
    - "A credential-load failure leaves the row pending for retry — the worker never PUTs with empty Basic-auth"
    - "The first transient failure waits 15s (BACKOFF_SECONDS[0]), not 60s"
  artifacts:
    - path: apps/api/src/broker/outboxWorker.ts
      provides: "ICS-building dispatch path + removed empty-cred fallback + corrected backoff index + explicit randomUUID import"
      contains: "buildVeventString"
    - path: apps/api/src/broker/vevent.ts
      provides: "All-day DTEND+1-day exclusivity fix (the WR-04 owning boundary)"
  key_links:
    - from: "apps/api/src/broker/outboxWorker.ts"
      to: "apps/api/src/broker/vevent.ts"
      via: "buildVeventString(parsedFormFields)"
      pattern: "buildVeventString\\("
    - from: "apps/api/src/broker/outboxWorker.ts"
      to: "apps/api/src/broker/write.ts"
      via: "createCalendarEvent/updateCalendarEvent with the built icsString"
      pattern: "createCalendarEvent\\(|updateCalendarEvent\\("
---

<objective>
Make the outbox worker actually write a valid calendar object. Today it PUTs the
raw form JSON (`{"title":...}`) to Fastmail — `buildVeventString` (the whole D-13
DATE/DATETIME contract) is dead code (CR-02). It also silently authenticates with
empty credentials on any credential-load error (CR-03), skips its first backoff
delay (WR-01), and mishandles the all-day exclusive DTEND (WR-04). This plan wires
the VEVENT builder into the dispatch path, adds a direct unit test that pins the
D-13 DATE-vs-DATETIME / RFC-5545 contract independent of the worker, and fixes
those correctness defects.

Purpose: a queued write becomes a real, RFC-5545-valid VEVENT on the correct calendar.
Output: a worker that builds ICS from the stored form fields and fails closed on bad credentials.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/03-event-write-back-pwa-install/03-REVIEW.md
@.planning/phases/03-event-write-back-pwa-install/03-09-SUMMARY.md
@apps/api/src/broker/outboxWorker.ts
@apps/api/src/broker/vevent.ts
@apps/api/src/broker/write.ts
</context>

<artifacts_this_phase_produces>
No new exported symbols. `buildVeventString` and `RRULE_PRESETS` (already exported by
vevent.ts) become live call sites for the first time. The worker's dispatch path gains
an internal `JSON.parse(row.payload)` → `buildVeventString` step.
</artifacts_this_phase_produces>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED+GREEN — worker builds and PUTs a real VEVENT (CR-02) + direct D-13 contract unit test + all-day DTEND+1 (WR-04)</name>
  <files>apps/api/src/broker/outboxWorker.ts, apps/api/src/broker/vevent.ts, apps/api/tests/broker/outboxWorker.test.ts, apps/api/tests/broker/vevent.test.ts</files>
  <read_first>
    - apps/api/src/broker/outboxWorker.ts (dispatchRow lines 131-188 — the create/update branches that pass row.payload straight through)
    - apps/api/src/broker/vevent.ts (buildVeventString signature lines 52-118; NewEventParams lines 21-33; RRULE_PRESETS lines 39-44; all-day DATE handling lines 69-88 — WR-04 lives in THIS branch)
    - apps/api/tests/broker/vevent.test.ts (existing direct unit tests: note the all-day test at lines 53-68 asserts DTSTART format but NOT DTEND+1 — the new contract block extends this)
    - apps/api/src/routes/events.ts (the route stores payload: JSON.stringify(payload) with the new title/start/end fields from plan 03-09)
    - apps/api/tests/broker/outboxWorker.test.ts (line ~85 hardcodes payload:'BEGIN:VCALENDAR' and mocks write.js — the wrong boundary; the new worker test must stop mocking the ICS string and assert the worker BUILDS it; match the existing vi.hoisted DB-mock + makeRow + makeResponse patterns)
    - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-02, WR-04)
  </read_first>
  <behavior>
    - RED (vevent UNIT — D-13 contract, the authoritative regression guard): in tests/broker/vevent.test.ts add a `describe('buildVeventString — D-13 form-parsed contract')` block that calls buildVeventString DIRECTLY (no worker in the loop) with the SAME field shape the worker parses from form JSON. Two cases:
        • timed: `{uid:'u1@familysync', summary:'Lunch', allDay:false, dtstart:new Date('2026-06-10T12:00:00Z'), dtend:new Date('2026-06-10T13:00:00Z')}` (recurrence omitted) → assert icsString contains `BEGIN:VCALENDAR`, `SUMMARY:Lunch`, `UID:u1@familysync`, a DTSTART line WITH a time component (matches `/DTSTART:\d{8}T\d{6}Z/`), and a DTEND line present (matches `/DTEND:\d{8}T\d{6}Z/`).
        • all-day single-day: `{summary:'Birthday', allDay:true, dtstart:'2026-06-10', dtend:'2026-06-10'}` → assert DTSTART is DATE format (matches `/DTSTART[^:]*:20260610/` and does NOT match `/DTSTART[^:]*:20260610T/` — no time), and DTEND = DTSTART + 1 day (matches `/DTEND[^:]*:20260611/`, RFC-5545 exclusive end), and the DTEND date string is NOT equal to the DTSTART date string.
      This unit test is the regression the worker integration test cannot catch: a vevent.ts regression would still pass the worker spy if both used the same broken builder. Fails today — the current all-day branch emits DTEND == DTSTART (no +1), so the `20260611` assertion fails.
    - RED (worker INTEGRATION — wiring, complementary to the unit test): with `write.js` NOT mocking away the payload — i.e. spy on `createCalendarEvent` and capture its 4th arg `icsString` — enqueue a create row whose `payload` is `JSON.stringify({title:'Lunch',allDay:false,start:'2026-06-10T12:00:00',end:'2026-06-10T13:00:00',recurrence:'none'})`. Assert the captured icsString `.startsWith('BEGIN:VCALENDAR')` and contains `SUMMARY:Lunch`. Fails today (raw JSON is passed).
    - RED (worker): an update row likewise yields an icsString starting with `BEGIN:VCALENDAR` passed to `updateCalendarEvent`.
    - RED (worker): a row whose `payload` is not valid JSON marks the row `failed` (hard fail, no retry).
  </behavior>
  <action>
    In `dispatchRow`, for `operation === 'create'` and `operation === 'update'`:
    `const fields = JSON.parse(row.payload)` wrapped in try/catch; on parse failure
    return `{success:false, conflict:false, hardFail:true, transient:false, error:'payload parse failed'}`
    (hard fail — corrupt payload will never self-resolve).
    Then build the ICS:
    `const { icsString } = buildVeventString({ uid: row.uid, summary: fields.title, allDay: fields.allDay,
      dtstart: fields.allDay ? fields.start : new Date(fields.start),
      dtend: fields.allDay ? fields.end : new Date(fields.end),
      location: fields.location, description: fields.description,
      rruleString: fields.recurrence && fields.recurrence !== 'none' ? RRULE_PRESETS[fields.recurrence] : undefined })`.
    Pass `icsString` (NOT `row.payload`) to `createCalendarEvent(client, davCalendar, row.uid, icsString)`
    and to `updateCalendarEvent(client, row.calendarObjectUrl, icsString, row.etag ?? null)`.
    Import `{ buildVeventString, RRULE_PRESETS }` from `./vevent.js`. Delete operations are unchanged (no payload).

    WR-04 — ONE owning boundary: the all-day DTEND+1 exclusivity fix lives in vevent.ts ONLY,
    NOT in form/route validation. Rationale: vevent.ts is the single serialization point for every
    write path, so fixing it there covers all callers; the form/route should keep passing the
    user-entered inclusive end date unchanged. In the all-day branch (vevent.ts lines 69-88), after
    parsing the end DATE components (ey/em/ed), advance the end DATE by one calendar day before
    constructing `endTime`: build a Date from ey/em/ed, `setUTCDate(getUTCDate()+1)`, re-read the
    rolled-over y/m/d, and use those for `endTime`. A one-day all-day event then serializes
    DTEND = DTSTART + 1. Keep the timed branch untouched. The acceptance test for WR-04 is the
    DIRECT vevent unit-test case above (the owning boundary), not the worker integration path.

    Update the existing outbox test that fed a pre-built ICS string so it instead feeds
    form JSON and asserts the built ICS (it was testing the wrong boundary). Commit RED then GREEN.
  </action>
  <verify>
    <automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - behavior (unit, owning boundary): a DIRECT buildVeventString call on a single-day all-day event yields DTEND = start + 1 day (`20260611`) and DTEND != DTSTART.
    - behavior (unit): a DIRECT buildVeventString call on a timed form-shaped event yields icsString containing BEGIN:VCALENDAR, SUMMARY:, UID:, a timed DTSTART (`/DTSTART:\d{8}T\d{6}Z/`), and a DTEND line.
    - behavior (integration): the icsString passed to createCalendarEvent starts with 'BEGIN:VCALENDAR' and contains the summary.
    - behavior: an unparseable payload marks the row failed with no retry.
    - source: `grep -c 'buildVeventString' apps/api/src/broker/outboxWorker.ts` returns >= 1.
    - source: `grep -c 'D-13 form-parsed contract' apps/api/tests/broker/vevent.test.ts` returns 1 (the new direct unit-test block exists).
    - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts` passes.
  </acceptance_criteria>
  <done>Every create/update PUTs a real RFC-5545 VCALENDAR built from the stored form fields; the D-13 DATE-vs-DATETIME contract and the exclusive all-day DTEND are pinned by a direct buildVeventString unit test that a worker-only test could not catch.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: RED+GREEN — fail closed on bad credentials, fix backoff index, explicit randomUUID (CR-03, WR-01, WR-08)</name>
  <files>apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
  <read_first>
    - apps/api/src/broker/outboxWorker.ts (dispatchRow try/catch fallback lines 135-143; backoff math lines 316-341; BACKOFF_SECONDS lines 40-44)
    - apps/api/src/routes/events.ts (uses bare `crypto.randomUUID()` at line 241 and the edit/move handlers — WR-08 is the route-side instance; vevent.ts already imports `randomUUID` from 'crypto')
    - .planning/phases/03-event-write-back-pwa-install/03-REVIEW.md (CR-03, WR-01, WR-08)
  </read_first>
  <behavior>
    - RED (CR-03): mock `loadClientForUser` (via the credential/decrypt path) to throw; assert the row is left `pending` (caught by the outer per-row catch in runOutboxDrain) and that `createFastmailClient('', '')` is NEVER invoked. Fails today (the catch falls back to empty creds and proceeds to PUT).
    - RED (WR-01): a transient failure on a row with attemptCount=0 sets nextAttemptAt ≈ now + 15s (BACKOFF_SECONDS[0]), not +60s.
  </behavior>
  <action>
    CR-03: Remove the `try { client = await loadClientForUser(row.userId) } catch { client = await createFastmailClient('','') }`
    fallback in `dispatchRow`. Replace with `const client = await loadClientForUser(row.userId)` and let it throw —
    the outer per-row `catch` in `runOutboxDrain` (line ~343) already logs and leaves the row pending (correct transient
    behavior). Tests that previously relied on the empty-cred fallback must instead mock `loadClientForUser`
    (or the underlying credential select + `createFastmailClient`) to return a fake client. Do NOT add a test-only
    flag that PUTs with empty creds.

    WR-01: change the backoff index from `nextAttemptCount` to `row.attemptCount` (the attempt that just failed):
    `const backoffMs = (BACKOFF_SECONDS[row.attemptCount] ?? 1800) * 1000`. Keep `nextAttemptCount = row.attemptCount + 1`
    for the persisted `attemptCount` and the `>= MAX_ATTEMPTS` dead-letter check. This makes the first retry wait 15s.

    WR-08: in events.ts replace every bare `crypto.randomUUID()` call (the create handler at line ~241 plus the
    edit/move handlers) with `randomUUID()` imported via `import { randomUUID } from 'node:crypto'`, matching
    vevent.ts. Confirm with grep that no bare `crypto.randomUUID(` remains. (events.ts is also edited by plan 03-09;
    this plan runs in a later wave so there is no concurrent edit.) Because this task edits events.ts but its vitest
    command only runs broker tests, the route edit is proven to COMPILE via the `npm run build` (tsc) assertion in
    this plan's <verification> and the acceptance criterion below — this closes Warning 5.

    Commit RED then GREEN.
  </action>
  <verify>
    <automated>cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts && cd apps/api && npm run build</automated>
  </verify>
  <acceptance_criteria>
    - behavior: a credential-load failure leaves the row pending and never calls createFastmailClient('', '').
    - behavior: first transient retry delay equals BACKOFF_SECONDS[0] (15s).
    - source: `grep -c "createFastmailClient('', '')" apps/api/src/broker/outboxWorker.ts` returns 0.
    - source: `grep -c "import { randomUUID } from 'node:crypto'" apps/api/src/routes/events.ts` returns 1.
    - source: `grep -c 'crypto.randomUUID(' apps/api/src/routes/events.ts` returns 0 (no bare calls remain).
    - test-command: `cd apps/api && npm run build` (tsc) succeeds — proves the edited events.ts route compiles (Warning 5 closed).
    - test-command: `cd apps/api && npx vitest run tests/broker/outboxWorker.test.ts` passes.
  </acceptance_criteria>
  <done>The worker fails closed on credential errors (retryable), uses the documented 15s-first backoff schedule, and uses an explicitly-imported randomUUID; the edited route is proven to compile via tsc.</done>
</task>

</tasks>

<verification>
- `cd apps/api && npx vitest run tests/broker/` green.
- `cd apps/api && npm run build` succeeds (also proves the WR-08 events.ts edit compiles — Warning 5).
- `grep -rn buildVeventString apps/api/src` shows a live call site outside vevent.ts (IN-01 closed).
</verification>

<success_criteria>
Queued writes serialize to valid VCALENDAR via buildVeventString, the D-13 DATE-vs-DATETIME contract and
exclusive all-day DTEND are pinned by a direct unit test, credential failures retry instead of writing with
empty auth, the backoff schedule matches its docs, and the edited route compiles.
CR-02, CR-03, WR-01, WR-04, WR-08, IN-01 closed.
</success_criteria>

<output>
Create `.planning/phases/03-event-write-back-pwa-install/03-10-SUMMARY.md` when done.
</output>
</content>
</invoke>
