chore(03): close Phase 03 — transition to Phase 04

- 03-08-SUMMARY (Gate 2 record) written; phase marked complete (12/12)
- ROADMAP: Phase 3 [x] complete 2026-06-07; REQUIREMENTS updated
- STATE: advanced to Phase 4, focus + progress corrected (backlog 999.x were
  miscounted as milestone phases → reset total_phases to 5, 60%)
- PROJECT.md evolved: event CRUD / OIDC login / iOS PWA install moved to
  Validated (Phase 3); D-17 logged; Android install + onboarding-provider-setup
  kept Active
- removed stale .continue-here + one-shot HANDOFF.json

Carried forward: B5 (Android install), Phase 4 SSE entry gate (D-14), backlog 999.3-999.9.
This commit is contained in:
Lucas Berger
2026-06-07 19:24:33 -04:00
parent d71b15cd02
commit cca5205173
6 changed files with 110 additions and 148 deletions
@@ -1,75 +0,0 @@
---
context: phase
phase: 03-event-write-back-pwa-install
task: "Gate 2 Part D — edit/delete join fix (diagnosed, not started)"
total_tasks: null
status: in_progress
last_updated: 2026-06-07T02:39:57.236Z
---
# Critical Anti-Patterns
| Pattern | Description | Severity | Prevention Mechanism |
|---------|-------------|----------|---------------------|
| playwright-cli wedges in this WSL2 env | After loading a never-settling page (e.g. the infinite-spinner state), the playwright-cli daemon hangs and even `open`/`run-code` fail afterward. Burned a lot of effort on it. | blocking | Do NOT use playwright-cli for verification here. Verify via `curl` against the tunnel + ask the operator to test in their real browser/incognito. |
| Drizzle: referencing joined-table columns without the join | `events.ts` edit + delete handlers select `calendars.url`/`calendars.userId` from `.from(calendarEvents)` with no `.innerJoin(calendars,...)` → runtime 503 "table calendars is not part of the query". Unit tests mock `db.select()` so they DON'T catch it. | blocking | Any handler selecting another table's columns MUST `.innerJoin` it. Regression tests for write endpoints must exercise the REAL query builder (test DB), not a mocked `db.select()`. |
| `.env` is permission-locked | The Read/Edit/Bash tools are denied on `.env` (and `.env.spike`). | advisory | Hand the operator exact `.env` lines to apply via the `!` prefix; never assume you can read/write it. |
| docker compose recreate drops the newt target ~30s | Every `docker compose up -d` recreates the api container, resetting newt's held TCP connection → tunnel returns 503 "no available server" for ~30s, then self-recovers. | advisory | After any recreate, poll `/health` through the tunnel until 200 before testing. Not a bug — do not chase it. |
<current_state>
Phase 03 **Gate 2 live verification is largely working.** The PWA now loads through Pangolin/newt, real Authelia OIDC login works, and create-event round-trips to Fastmail correctly (right time, right user) after this session's fixes. Working tree is clean (all committed).
**Immediate blocker:** deleting (and latently editing) an event 503s — the `events.ts` edit/delete handlers are missing a `calendars` join. Diagnosed, fix NOT yet applied. The operator paused right as I asked how to land the fix.
</current_state>
<completed_work>
This session (commits b46b25b → bdbb9b8):
- **Tunnel works** — operator set `newt -mtu 1200` (was 1280 == eth0 underlay; WireGuard overhead blackholed large packets). THIS was the real cause of every "spinner" — the 510KB JS bundle never downloaded. Plus API now serves the full `./public` tree (431ab31), so manifest/sw/icons stop returning HTML.
- **Auth works** — `/api/login` route + redirect (quick task 260606-tv8), `fetchMe` uses `redirect:'manual'` (1adb460), `OIDC_CLIENT_ID=familysync-dev`, `OIDC_SCOPES=openid profile email offline_access`, redirect URI corrected. Operator added `offline_access` to the Authelia client.
- **Bring-up** (b46b25b) — PWA built into the API image (single port :3000), `NODE_ENV=production`, broker credential seeded (reused the spike app password).
- **BUG A (timezone)** + **BUG B (calendar identity)** fixed via /gsd-debug (a9d3de6, session `.planning/debug/write-path-event-bugs.md`). Migration `0001_calendars_user_url_unique.sql` applied to the live DB.
- **Spike cleanup** — deleted obsolete user id=1 ("Dev User") + calendar id=1 + cached events (operator-approved DB op).
- **Backlog 999.2** added — slick unauthenticated-entry (no login flash).
</completed_work>
<remaining_work>
1. **BLOCKING — edit/delete join fix.** `apps/api/src/routes/events.ts`: the `PATCH /:uid/edit` lookup (~line 295) and `DELETE /:uid` lookup (~line 392) select `calendarUrl: calendars.url` + `userId: calendars.userId` from `.from(calendarEvents).where(eq(calendarEvents.uid, uid))` with NO join. Add `.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))` to both (mirror the working `GET /api/events` query at ~line 153). Add a regression test that runs the real query builder.
2. `me.ts` blank displayName — passes the (missing) `email` claim as displayName, never reads `name`/`preferred_username`; `oidc_iss` also blank. Legend name is blank (user id=2 `display_name=''`). May also need Authelia to include name/email in the ID token, or call userinfo.
3. `GET /api/events` has no `userId`/`isShared` filter (returns all users' events) — latent now (1 real user), real bug for a 2nd member.
4. "Syncing" toast not animated / ~27s — UI polish (backlog candidate).
5. Gate 2 remaining: A2 (session persistence), A3 (2nd-member color), B (iOS standalone install+login — device-only), C (5-min SSE smoke — Phase 4 entry gate).
The operator was choosing how to land #1: **(a)** batch #1+#2+#3 in one /gsd-quick, **(b)** /gsd-quick just #1, or **(c)** fix #1 inline. Re-offer that.
</remaining_work>
<decisions_made>
- `newt -mtu 1200` — fixes large-asset blackhole through the WireGuard tunnel.
- Deleted the spike user (id=1) + its calendar/events — obsolete test identity, re-syncable cache.
- `fetchMe` `redirect:'manual'`, serve full `./public`, constrained `OIDC_SCOPES` — see HANDOFF.json.
</decisions_made>
<blockers>
- Delete/edit events 503 (missing calendars join) — trivial fix, not yet applied.
- playwright-cli broken in this env — verify via curl + operator's browser.
</blockers>
## Required Reading (in order)
1. `.planning/HANDOFF.json` — machine-readable mirror of this state.
2. `apps/api/src/routes/events.ts` — the edit (~line 295) + delete (~line 392) handlers missing the `calendars` join; compare to the working GET query (~line 153).
3. `.planning/debug/write-path-event-bugs.md` — the resolved timezone + calendar-identity debug session (context for the write path).
4. `.planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md` — the Gate 2 checklist (still needs updating with what now passes).
## Infrastructure State
- Stack: `docker compose` (production target) — api + mariadb (healthy) + redis up. `/health` 200 locally AND through `https://familysync-dev.bergerhouse.net`.
- newt: systemd service, `-mtu 1200` drop-in applied by operator; WireGuard tunnel carries large transfers now.
- DB: 1 user (id=2, real OIDC, `display_name=''`, color #E8734A); calendars id=2 "Calendar" (509 ev) + id=3 "USA Holidays" (32). `uniq_calendar_user_url(user_id,url)` present.
- OIDC: `client_id=familysync-dev` registered in Authelia with `offline_access`; redirect `https://familysync-dev.bergerhouse.net/callback`.
- An old test event (uid `92dd5a80…`) exists in Fastmail at the WRONG time (written pre-BUG-A-fix) — operator should delete it via the app once delete works.
<context>
The whole session was a Gate 2 bring-up that turned into a bug hunt. The keystone was the newt MTU fix — until then the JS bundle couldn't traverse the tunnel, so the app showed a perpetual spinner that looked like (and got misdiagnosed as) auth problems. After that unlocked, live testing surfaced a cascade of real write-path bugs, most now fixed. Remaining work is small and well-understood; the edit/delete join is a 2-line fix gated only on the operator's choice of how to land it.
</context>
<next_action>
Start with: re-offer the operator the landing choice for the edit/delete join fix (batch #1+#2+#3 / quick-only #1 / inline). Then apply `.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))` to the edit (~line 295) and delete (~line 392) handlers in `apps/api/src/routes/events.ts`, add a real-query regression test, `docker compose up -d --build`, wait for tunnel `/health` 200, and have the operator re-test delete in the browser.
</next_action>
@@ -0,0 +1,79 @@
---
phase: 03-event-write-back-pwa-install
plan: 08
subsystem: gate, live-verification, auth, broker, pwa
tags: [gate-2, live-verification, authelia, oidc, pangolin, ios-pwa, caldav, write-back]
# Dependency graph
requires:
- phase: 03-event-write-back-pwa-install
plan: 04
provides: write endpoints (create/edit/delete) + outbox
- phase: 03-event-write-back-pwa-install
plan: 06
provides: EventDetailPopover + DeleteConfirmationDialog + SyncStateToast
- phase: 03-event-write-back-pwa-install
plan: 07
provides: PWA manifest + service worker + InstallPrompt
provides:
- Gate 2 live-verification results against the real Authelia + Pangolin deploy
- Confirmed end-to-end write path (create/all-day/recurring/edit/delete/conflict) to Fastmail
- Confirmed iOS standalone install + OIDC login (load-bearing)
affects: [phase-04]
# Tech tracking
tech-stack:
added: []
patterns:
- "Live Mode-A topology: local origin + Newt connector + Authelia OIDC through Pangolin"
- "Operator-driven verification (playwright-cli unavailable in WSL2); evidence via DB/outbox + browser"
key-files:
created:
- .planning/phases/03-event-write-back-pwa-install/03-08-SUMMARY.md
modified:
- .planning/phases/03-event-write-back-pwa-install/03-GATE2-RESULTS.md
---
# Phase 03 Plan 08: Gate 2 Live Verification — Summary
**One-liner:** Took FamilySync live (real Authelia OIDC over Pangolin/Newt) and verified the full event write-back path end-to-end to Fastmail on desktop and iOS, fixing a long string of blocker bugs found only under live conditions.
## Outcome
Gate 2 is **complete for Phase 03 scope**. See `03-GATE2-RESULTS.md` for the per-row record. Summary:
- **A — Auth/session/colors:** A1 (OIDC login → app) ✅, A2 (session — transparent via Authelia SSO) ✅, A3 (distinct member colors) ✅ after fixing a color-collision bug.
- **B — iOS standalone (load-bearing):** B1B4 ✅ — install to Home Screen, full-screen standalone launch, and **OIDC login completed from standalone without dropping to Safari**. B5 (Android install) deferred.
- **C — SSE smoke:** deferred by design — this is the Phase 4 *entry* gate (D-14), verified at the start of Phase 4.
- **D — write round-trips:** D1D6 ✅ — create (timed), all-day, weekly recurring, edit, delete, 412-conflict, plus recurring-series delete, all round-tripping to caldav.fastmail.com.
## Blocker bugs found + fixed live (all committed + deployed)
Live bring-up surfaced bugs the dev-bypass build could not:
- **Tunnel:** newt MTU 1280→1200 (operator) — encrypted WireGuard packets exceeded the underlay MTU, blackholing the JS bundle (the original "spinner"). API now serves the full `./public` tree.
- **Auth:** `/api/login` route + `fetchMe` `redirect:'manual'`; OIDC scopes/client_id; and the OIDC **state-cookie churn** (events query racing the login flow → `OAUTH_INVALID_RESPONSE`) — fixed by gating the events query on auth.
- **Write path:** event timezone (UTC serialization), per-user calendar identity (unique(userId,url) + per-user predicates), missing `calendars` join in edit/delete (503), delete **cache reconciliation** (deletes lingered as ghosts), and the post-write **refetch race** (resync now precedes marking the outbox row done).
- **UI:** calendar **remount flash** (nested component rendered as `<CalendarContent/>`), all-day **display off-by-one** (exclusive DTEND vs Schedule-X inclusive), member **color collision** and member-vs-shared **color clash**.
- **Identity:** displayName now derived from OIDC claims with self-heal (Authelia ID-token `claims_policy` documented as the operator step for full names).
## Deferred / carried forward
- **B5** — Android install walkthrough (device check).
- **C** — SSE 5-min smoke (Phase 4 entry gate, D-14).
- **Backlog 999.3999.9** — session-timeout sign-in redirect; event reminder/VALARM options; first-login Fastmail app-password provider setup; all-day visual distinction; event-form end-tracking + all-day edit off-by-one; recurrence repeat-until/count bound; edit recurring series.
## Verification method
Operator-driven browser testing (desktop + the wife's iPhone) + backend evidence (`calendar_outbox` rows reaching `done`, `calendar_events` cache, stored VEVENTs). `playwright-cli` is unavailable in this WSL2 env, so desktop rows were operator-driven rather than automated.
## Self-Check
- [x] Gate 2 results recorded in `03-GATE2-RESULTS.md`
- [x] Write path (create/all-day/recurring/edit/delete/conflict) verified live to Fastmail
- [x] iOS standalone install + login (load-bearing) verified
- [x] All live blocker bugs fixed, committed, and deployed
- [x] UX gaps captured as backlog (999.3999.9); B5/C deferred by design