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)
path
provides
contains
apps/api/src/broker/outboxWorker.ts
assembleRruleString helper + UNTIL/COUNT assembly wired into the write payload
recurrenceUntil/recurrenceCount in enqueued payload
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.
<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>
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.
<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.
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>
- `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)`.
<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>
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).