---
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"
---
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`.
@$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/06-ux-polish/06-RESEARCH.md
@.planning/phases/06-ux-polish/06-PATTERNS.md
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)
Task 1: RED — failing tests for UNTIL/COUNT serialization + FREQ-persistence regression
apps/api/tests/broker/vevent.test.ts, apps/api/tests/broker/outboxWorker.test.ts
- 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
- 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).
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`.
cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker 2>&1 | grep -E "COUNT=5|assembleRruleString|FREQ persistence" && echo "RED present"
- 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.
UNTIL/COUNT serialization + FREQ-persistence regression tests written and RED; committed.
Task 2: GREEN — assembleRruleString + Zod schema acceptance, wired into the write path
apps/api/src/broker/outboxWorker.ts, apps/api/src/routes/events.ts, apps/api/src/broker/vevent.ts
- 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)
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`.
cd apps/api && pnpm test -- run broker/vevent broker/outboxWorker
- `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).
UNTIL/COUNT serialize value-type-matched; daily persists as FREQ=DAILY; schemas accept the new fields; suites green; RED→GREEN order present.
## 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). |
- `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)`.
- 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.