---
phase: 06-ux-polish
plan: 02
type: tdd
wave: 1
depends_on: []
files_modified:
  - apps/api/src/broker/vevent.ts
  - apps/api/src/broker/outboxWorker.ts
  - apps/api/src/routes/events.ts
  - apps/api/tests/broker/vevent.test.ts
  - apps/api/tests/broker/outboxWorker.test.ts
autonomous: true
requirements: []
must_haves:
  truths:
    - 'Scope fence (D-01/D-02): recurrence bounding only — NO VALARM/reminder serialization (999.4) is added to the write path; reminders are deferred to milestone 1.1'
    - 'A recurring series can be bounded by a repeat-until date (RRULE UNTIL) or an occurrence count (RRULE COUNT) (D-06)'
    - 'All-day UNTIL serializes as a DATE (YYYYMMDD); timed UNTIL serializes as a UTC DATETIME (YYYYMMDDT235959Z) (D-06, RFC 5545 §3.3.10)'
    - "A 'daily' frequency selection persists as FREQ=DAILY end-to-end through the outbox (D-07)"
  artifacts:
    - path: 'apps/api/src/broker/outboxWorker.ts'
      provides: 'assembleRruleString helper + UNTIL/COUNT assembly wired into the write payload'
      contains: 'assembleRruleString'
    - path: 'apps/api/src/routes/events.ts'
      provides: 'eventFieldsSchema accepts recurrenceUntil + recurrenceCount'
      contains: 'recurrenceUntil'
    - path: 'apps/api/tests/broker/vevent.test.ts'
      provides: 'UNTIL-DATE, UNTIL-DATETIME, COUNT serialization assertions'
      contains: 'COUNT=5'
  key_links:
    - from: 'apps/api/src/broker/outboxWorker.ts'
      to: 'apps/api/src/broker/vevent.ts'
      via: 'assembled rruleString passed to buildVeventString'
      pattern: 'assembleRruleString|rruleString'
    - from: 'apps/api/src/routes/events.ts'
      to: 'apps/api/src/broker/outboxWorker.ts'
      via: 'recurrenceUntil/recurrenceCount in enqueued payload'
      pattern: 'recurrenceUntil|recurrenceCount'
---

<objective>
Deliver the server-side recurrence-bounding write path (D-06) and the FREQ-persistence regression lock (D-07). This is the API half of 999.8: the event write path must serialize `RRULE UNTIL`/`COUNT` correctly (value-type-matched to DTSTART per RFC 5545), and a daily selection must round-trip as `FREQ=DAILY`.

The PWA UI control that produces `recurrenceUntil`/`recurrenceCount` is built in Plan 06; the `CreateEventPayload` type fields that carry them are added in Plan 05 (sole owner of `client.ts`). This plan owns the API contract: Zod acceptance + ical.js serialization + tests.

Purpose: RRULE serialization is deterministic ICS-string output — a TDD candidate. Per RESEARCH, adding UNTIL/COUNT does NOT affect per-occurrence duration (that bug is D-04's end-tracking, fixed in Plan 01), so this plan is self-contained on the API side.
Output: `assembleRruleString` in `outboxWorker.ts`, extended Zod schema in `events.ts`, green `vevent.test.ts` + `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/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
</context>

<artifacts_this_plan_produces>
NEW symbols introduced here (exclude from drift/convergence checks):

- `assembleRruleString(basePreset, until?, count?, allDay?): string` in `apps/api/src/broker/outboxWorker.ts`
- Two new optional fields on `eventFieldsSchema` (`events.ts`) and `outboxPayloadSchema` (`outboxWorker.ts`): `recurrenceUntil` ('YYYY-MM-DD'), `recurrenceCount` (int ≥ 1)
- New test cases in `vevent.test.ts` (COUNT, UNTIL DATE, UNTIL DATETIME) and `outboxWorker.test.ts` (FREQ-persistence regression, bound-assembly)
  </artifacts_this_plan_produces>

<tasks>

<task type="tdd" tdd="true">
  <name>Task 1: RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression</name>
  <files>apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
  <read_first>
    - apps/api/tests/broker/vevent.test.ts — existing `buildVeventString` describe block (the exact test structure to mirror; PATTERNS gives copy-ready cases)
    - apps/api/tests/broker/outboxWorker.test.ts — existing outbox-worker test setup (mock DB rows, payload shape) to mirror for the FREQ regression
    - apps/api/src/broker/vevent.ts — `RRULE_PRESETS` (lines 49–54) + the `ICAL.Recur.fromString` + `rruleProp.setValue` serialization path (lines ~143–148)
    - .planning/phases/06-ux-polish/06-RESEARCH.md §"Focus 1" + §"Focus 2" + §"Code Examples" — verified ical.js 2.2.1 UNTIL/COUNT output strings; FREQ-persistence diagnosis
    - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/tests/broker/vevent.test.ts" — three copy-ready test cases
  </read_first>
  <behavior>
    - buildVeventString with rruleString 'FREQ=WEEKLY;COUNT=5' (timed) → ICS contains `RRULE:FREQ=WEEKLY;COUNT=5`.
    - buildVeventString with rruleString 'FREQ=DAILY;UNTIL=20260630' (all-day, isDate) → ICS contains `RRULE:FREQ=DAILY;UNTIL=20260630` and does NOT contain `T235959Z`.
    - buildVeventString with rruleString 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed) → ICS contains `RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z`.
    - assembleRruleString('FREQ=DAILY', undefined, 5, false) → 'FREQ=DAILY;COUNT=5'.
    - assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true) → 'FREQ=WEEKLY;UNTIL=20260630' (all-day DATE form).
    - assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false) → 'FREQ=WEEKLY;UNTIL=20260630T235959Z' (timed DATETIME form).
    - assembleRruleString with BOTH until and count present → COUNT wins, UNTIL omitted (mutual exclusion, RFC 5545 §3.3.10).
    - FREQ-persistence regression (D-07): an outbox payload with `recurrence:'daily'` and no bound assembles to an rruleString of exactly `FREQ=DAILY` (NOT weekly/none).
  </behavior>
  <action>
    In `vevent.test.ts`, add the three serialization cases from PATTERNS (COUNT, UNTIL-DATE, UNTIL-DATETIME). In `outboxWorker.test.ts`, add a `describe('assembleRruleString (D-06)')` block importing the not-yet-exported helper, plus a `describe('FREQ persistence (D-07 regression)')` case asserting that a daily-recurrence payload yields `FREQ=DAILY`. Use ical.js output strings VERIFIED in RESEARCH (do not invent formats). Run both suites and CONFIRM RED (missing `assembleRruleString` export + unimplemented UNTIL/COUNT path). Commit: `test(06-02): add failing tests for RRULE UNTIL/COUNT + FREQ persistence`.
  </action>
  <verify>
    <automated>cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker 2>&1 | grep -E "COUNT=5|assembleRruleString|FREQ persistence" && echo "RED present"</automated>
  </verify>
  <acceptance_criteria>
    - New cases exist in both test files with the exact expected ICS/RRULE strings from RESEARCH.
    - Suites show the new tests FAILING for the right reason (missing export / unimplemented path), not import errors.
    - A `test(06-02): ...` commit exists.
  </acceptance_criteria>
  <done>UNTIL/COUNT serialization + FREQ-persistence regression tests written and RED; committed.</done>
</task>

<task type="tdd" tdd="true">
  <name>Task 2: GREEN — assembleRruleString + Zod schema acceptance, wired into the write path</name>
  <files>apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/src/broker/vevent.ts</files>
  <read_first>
    - apps/api/src/broker/outboxWorker.ts — `outboxPayloadSchema` (lines 71–83) and the existing RRULE assembly site (lines ~255–308: `hasExplicitRecurrence`, `RRULE_PRESETS[fields.recurrence]`, preservedRrule path)
    - apps/api/src/routes/events.ts — `eventFieldsSchema` (lines 100–109, the `recurrence: z.enum(...)` field) — add the two optional fields here mirroring the `location`/`description` `.optional()` style
    - apps/api/src/broker/vevent.ts — confirm the `ICAL.Recur.fromString(params.rruleString)` path (lines ~143–148) handles UNTIL/COUNT unchanged (RESEARCH: verified — no vevent.ts logic change needed beyond receiving the assembled string)
    - .planning/phases/06-ux-polish/06-PATTERNS.md §"apps/api/src/broker/outboxWorker.ts" — the exact `assembleRruleString` body + the series-edit "strip existing UNTIL/COUNT then re-apply" branch
    - .planning/phases/06-ux-polish/06-RESEARCH.md §"Pitfall 1/2/3" — value-type matching, T235959Z trade-off, parse-then-modify (do NOT blindly concatenate onto a rich preserved RRULE)
  </read_first>
  <action>
    Add `recurrenceUntil: z.string().max(10).optional()` and `recurrenceCount: z.number().int().min(1).optional()` to BOTH `eventFieldsSchema` (events.ts) and `outboxPayloadSchema` (outboxWorker.ts), mirroring the existing `.optional()` field style. Implement `assembleRruleString(basePreset, until?, count?, allDay?)` per PATTERNS: COUNT takes precedence (`;COUNT=N`); else UNTIL → all-day emits `;UNTIL=${until.replace(/-/g,'')}` (DATE form `20260630`), timed emits `;UNTIL=${...}T235959Z` (DATETIME UTC). Wire it into the existing assembly site: when an explicit preset is present, assemble `preset + bound`; on series edit where only the bound changes (preserved RRULE present, no new preset), parse the preserved RRULE, STRIP any existing `;(UNTIL|COUNT)=...` via the regex in PATTERNS, then re-apply the new bound — never naive-concatenate onto `FREQ=WEEKLY;BYDAY=...`. Pass the assembled string to `buildVeventString` unchanged. Use the verified `T235959Z` end-of-UTC-day choice for timed UNTIL (RESEARCH A2). Run suites to GREEN. Commit: `feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence`.
  </action>
  <verify>
    <automated>cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker</automated>
  </verify>
  <acceptance_criteria>
    - `assembleRruleString` is exported and produces the exact strings asserted in Task 1.
    - `recurrenceUntil` + `recurrenceCount` are accepted by both Zod schemas (rejecting count < 1 and over-length until strings).
    - All new tests pass; the full `vevent` + `outboxWorker` suites still pass (no regression to existing recurrence handling).
    - Series-edit bound change strips-then-reapplies (a test or assertion shows `FREQ=WEEKLY;BYDAY=MO` + new UNTIL does not produce a double-UNTIL).
    - `feat(06-02): ...` commit follows the `test(06-02): ...` commit (RED→GREEN).
  </acceptance_criteria>
  <done>UNTIL/COUNT serialize value-type-matched; daily persists as FREQ=DAILY; schemas accept the new fields; suites green; RED→GREEN order present.</done>
</task>

</tasks>

<threat_model>

## Trust Boundaries

| Boundary                              | Description                                                                                                                                                  |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| client → API (POST/PATCH /api/events) | `recurrenceUntil` / `recurrenceCount` are new untrusted inputs crossing into the write path and ultimately into an ICS RRULE string sent to Fastmail CalDAV. |

## STRIDE Threat Register

| Threat ID  | Category  | Component                                                                   | Disposition | Mitigation Plan                                                                                                                                                                                                                                                                                                                                                         |
| ---------- | --------- | --------------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| T-06-02    | Tampering | recurrenceUntil/recurrenceCount → RRULE string (events.ts, outboxWorker.ts) | mitigate    | Zod `z.string().max(10)` on `recurrenceUntil` + `z.number().int().min(1)` on `recurrenceCount` at the route boundary; `assembleRruleString` only emits digits from a `replace(/-/g,'')` of a length-bounded string; final string is re-parsed by `ICAL.Recur.fromString` which rejects malformed RRULE — no raw passthrough to the ICS. (V5 Input Validation, ASVS L1.) |
| T-06-02b   | Tampering | RRULE injection via crafted until value                                     | mitigate    | The `.replace(/-/g,'')` plus the fixed `;UNTIL=`/`;COUNT=` templates prevent injecting extra `;`-delimited RRULE parts; `ICAL.Recur.fromString` sanitizes via parse. A date that is not `YYYY-MM-DD` produces a non-date string that ical.js rejects or normalizes — fails closed (event enqueue errors), no silent corruption.                                         |
| T-06-02-SC | Tampering | npm installs                                                                | accept      | No package installs in this plan (RESEARCH: Package Legitimacy Audit not applicable — zero new deps).                                                                                                                                                                                                                                                                   |

</threat_model>

<verification>
- `cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker` green.
- `grep -n "recurrenceUntil" apps/api/src/routes/events.ts apps/api/src/broker/outboxWorker.ts` shows the field in both schemas.
- Git log: `test(06-02)` precedes `feat(06-02)`.
</verification>

<success_criteria>

- D-06: bounded recurrence serializes correctly, value-type-matched to DTSTART.
- D-07: daily→FREQ=DAILY regression is locked by an automated test.
- API contract (`recurrenceUntil`/`recurrenceCount`) is live for Plan 06's UI to drive.
  </success_criteria>

<output>
Create `.planning/phases/06-ux-polish/06-02-SUMMARY.md` when done (RED/GREEN notes + commits; note any Fastmail UNTIL value-type observation for the verify step).
</output>
