{
  "_meta": {
    "updated_at": "2026-06-09T18:56:37.788Z",
    "commit": "99f59c3999f4ef992f01ef8f8a38c1d86d2f2a0f",
    "version": 3
  },
  "entries": {
    "broker-cache-api-pattern": {
      "title": "Broker-Cache-API pattern (two planes never cross)",
      "decision": "Backend split into a broker plane (apps/api/src/broker/) that owns all Fastmail I/O and an API plane (apps/api/src/routes/) that reads only from MariaDB. Broker crons are not reachable from the HTTP layer.",
      "files": [
        "apps/api/src/broker/poller.ts",
        "apps/api/src/broker/outboxWorker.ts",
        "apps/api/src/routes/"
      ]
    },
    "write-broker-boundary": {
      "title": "Write-broker boundary invariant",
      "decision": "No route file imports tsdav or createFastmailClient; no broker file handles HTTP requests. Routes enqueue calendar_outbox rows and return 202 (optimistic-accept); the outbox worker performs the Fastmail write asynchronously.",
      "files": ["apps/api/src/routes/events.ts", "apps/api/src/broker/write.ts"]
    },
    "identity-keying": {
      "title": "Identity keyed on oidc_iss + oidc_sub",
      "decision": "Users are keyed on oidc_iss + oidc_sub (never email). A hex color from the palette is auto-assigned on first login.",
      "files": ["apps/api/src/routes/me.ts", "apps/api/src/auth/middleware.ts"]
    },
    "D-03-writable-set": {
      "title": "D-03 calendar ownership / writable-set predicate",
      "decision": "Every writable-set query uses WHERE userId = currentUser.id OR isShared = true. Another member's personal calendar is a read-only overlay.",
      "files": ["apps/api/src/routes/events.ts"]
    },
    "D-13-dual-field-dtstart": {
      "title": "D-13 all-day vs timed events (dual dtstart fields)",
      "decision": "dtstart_utc is NULL for all-day events; dtstart_date is NULL for timed events. Never coerce DATE to DATETIME.",
      "files": ["apps/api/src/db/schema.ts"]
    },
    "D-16-shared-fastmail-account": {
      "title": "D-16 shared Fastmail account, per-member credentials",
      "decision": "Both members share one Fastmail account. Calendar identity in DB is (userId, url) — the same collection URL appears once per member credential. CalDAV credential per member is stored AES-256-GCM encrypted in member_credentials.",
      "files": ["apps/api/src/db/schema.ts", "apps/api/src/broker/poller.ts"]
    },
    "outbox-status-machine": {
      "title": "Outbox status machine",
      "decision": "calendar_outbox rows transition pending -> done | failed | dead. failed rows retry up to a limit; dead is terminal. The sync-status endpoint surfaces worst-status-first per uid.",
      "files": ["apps/api/src/broker/outboxWorker.ts", "apps/api/src/routes/events.ts"]
    },
    "oidc-behind-pangolin": {
      "title": "OIDC behind Pangolin requires OIDC_AUTH_EXTERNAL_URL",
      "decision": "OIDC_AUTH_EXTERNAL_URL must be set to the public HTTPS URL to construct a correct redirect_uri; without it the callback resolves to the internal container address.",
      "files": ["apps/api/src/auth/middleware.ts", "apps/api/src/index.ts"]
    },
    "dev-auth-bypass": {
      "title": "Dev auth bypass",
      "decision": "DEV_AUTH_BYPASS=true with NODE_ENV!=production injects DEV_USER via Hono context; OIDC middleware is never mounted in this mode.",
      "files": ["apps/api/src/auth/devBypass.js", "apps/api/src/index.ts"]
    },
    "pwa-static-serving": {
      "title": "PWA static serving + SPA fallback",
      "decision": "Hono serveStatic serves ./public (Vite build output); SPA routes fall through to an index.html catch-all registered after /health, /api/*, and /callback so those win.",
      "files": ["apps/api/src/index.ts"]
    },
    "schedule-x-routing": {
      "title": "Schedule-X calendar routing",
      "decision": "Events are routed to Schedule-X calendars by isShared ? 'shared' : String(ownerUserId) — never by calendarId. hydrateEvents.ts enforces this.",
      "files": ["apps/pwa/src/lib/hydrateEvents.ts", "apps/pwa/src/components/CalendarShell.tsx"]
    },
    "state-ownership": {
      "title": "Client state ownership split",
      "decision": "Server state is owned by TanStack Query; UI-only state (selected range, color map, drawer) by Zustand. Schedule-X renders the calendar UI.",
      "files": ["apps/pwa/src/store/calendarStore.ts", "apps/pwa/src/components/CalendarShell.tsx"]
    },
    "lists-storage-mariadb-not-caldav": {
      "title": "Lists stored in MariaDB, not CalDAV (Phase 4)",
      "decision": "Named lists and items are app-owned data in MariaDB (lists, list_items, list_shares tables), not pushed to Fastmail. CalDAV is exclusively for calendar events.",
      "files": ["apps/api/src/db/schema.ts", "apps/api/src/routes/lists.ts"]
    },
    "D-01-D-02-list-sharing": {
      "title": "D-01/D-02 list sharing via join table (member-count-agnostic)",
      "decision": "isShared=true (default) triggers auto-insert of list_shares rows for all other users at create/patch time. Shares are server-managed only — no client-writable shares endpoint (T-04-08). list_shares join table is member-count-agnostic for future N-member expansion.",
      "files": ["apps/api/src/routes/lists.ts", "apps/api/src/db/schema.ts"]
    },
    "D-04-scoped-sse-fan-out": {
      "title": "D-04 scoped SSE fan-out — per-list channels, not global",
      "decision": "GET /api/sse/lists resolves the caller's accessible list IDs via getAccessibleListIds, then subscribes one listEmitter channel per ID. Private lists of other members are never delivered. In-memory EventEmitter singleton (D-18) — no Redis; single-process, no replicas.",
      "files": [
        "apps/api/src/routes/sse.ts",
        "apps/api/src/lib/listEmitter.ts",
        "apps/api/src/lib/listAccess.ts"
      ]
    },
    "D-08-per-field-lww-patch": {
      "title": "D-08 per-field last-write-wins PATCH for list items",
      "decision": "PATCH /api/list-items/:itemId accepts exactly one field (checked | text | position). Zod enforces single-field constraint. Prevents one client's stale read overwriting concurrent updates to other fields.",
      "files": ["apps/api/src/routes/lists.ts"]
    },
    "D-13-fractional-rank": {
      "title": "D-13 fractional-indexing rank for list item ordering",
      "decision": "list_items.rank is a varchar(255) COLLATE utf8mb4_bin using fractional-indexing strings. A single drag-reorder writes only the moved item's rank (one-row write). utf8mb4_bin collation required so uppercase-prefixed ranks (e.g. 'Zz') sort before lowercase (e.g. 'a0'), matching JS string order.",
      "files": [
        "apps/api/src/db/schema.ts",
        "apps/api/src/lib/rank.ts",
        "apps/pwa/src/routes/ListDetail.tsx"
      ]
    },
    "D-10-D-11-D-12-sse-resilience": {
      "title": "D-10/D-11/D-12 SSE resilience: invalidate-not-patch, bounded backoff, polling fallback",
      "decision": "D-10: SSE events carry minimal { type, listId } payload; client full-refetches via TanStack Query invalidation rather than patching cache from event payload. D-11: useListSSE implements bounded backoff (250ms→8s cap, MAX_ATTEMPTS then give-up). D-12: 30s polling fallback always active in ListDetail as safety net.",
      "files": ["apps/pwa/src/hooks/useListSSE.ts", "apps/pwa/src/routes/ListDetail.tsx"]
    },
    "react-router-spa-shell": {
      "title": "react-router BrowserRouter SPA shell with BottomTabBar",
      "decision": "App.tsx wraps routes in BrowserRouter with declarative Routes. BottomTabBar is a sibling of Routes (not inside) so it persists across navigation. SW navigateFallback covers /lists/* deep-links.",
      "files": ["apps/pwa/src/App.tsx", "apps/pwa/src/components/BottomTabBar.tsx"]
    }
  }
}
