166 lines
12 KiB
Markdown
166 lines
12 KiB
Markdown
---
|
||
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>
|