# reminders.etpics.com — Plan

A personal "talk to it and it sorts itself out" assistant. You open a PWA, tap record,
ramble (and optionally snap/attach a photo for context), and walk away. Your home Mac
transcribes the audio (free, local whisper.cpp), asks Claude (free, Claude Code CLI headless)
what you meant — reading any attached images as visual context — and turns it into the right
thing: an **Apple Reminder**, an **Apple Calendar event**, or a **logged thought** in your
personal-assistant memory. The website gives you richer custom views on top of what's
already mirrored from Apple's apps.

Decisions locked with Eric (2026-05-30):
- **Apple sync:** home-Mac agent via EventKit (full fidelity, every list, reminders *and*
  calendar). Mac-dependent, accepted.
- **Transcription:** whisper.cpp local on the Mac. Avoid all cost.
- **AI routing:** Claude Code CLI headless on the Mac (subscription, not metered API).
  Avoid all cost.
- **Captures** may be voice, image(s), typed text, or any mix; images are AI visual context.
- **Trigger:** VPS pushes work to the Mac over SSH (eorganize-style), no Mac polling.
- **`etk` Swift EventKit helper:** approved — ship it in the repo.
- **SSH key:** a fresh dedicated keypair for reminder, locked to a forced command.
- Building now. Milestone 1 = scaffold + deploy skeleton (this commit).

---

## 1. Where it fits in your ecosystem

It is the union of two apps you already run:

- **eorganize** — browser `MediaRecorder` → audio on disk → whisper.cpp on home Mac →
  Claude turns text into strict JSON → results written back. We reuse this pipeline
  almost verbatim.
- **quailmanager** — bearer-token API endpoints + a headless Claude (tmux / Claude Code)
  that polls/acts and posts results back. We reuse this control pattern for the agent.

House conventions it inherits (no deviation):

| Concern        | Convention                                                              |
|----------------|-------------------------------------------------------------------------|
| Stack          | Next.js 16 (App Router, standalone) + Postgres 16 + Prisma 6 + Docker   |
| Reverse proxy  | in-container nginx, cPanel/Apache fronts it                             |
| **Port**       | **8543** (next free after quail 7443; 9443/9543 taken)                  |
| Subdomain      | `reminders.etpics.com`                                                    |
| TLS            | cPanel cert at `/var/cpanel/ssl/apache_tls/reminders.etpics.com/combined`|
| Deploy         | `deploy.sh` → rsync to `root@etpics.com:/home/reminder/public_html`     |
| Compose proj   | `reminder_prod`, network `reminder_net_prod`                            |
| Auth           | scrypt password + HS256 JWT cookie (`rem_session`), like eorganize       |

---

## 2. Architecture

```
 iPhone / browser (PWA)
        │  tap record, talk, upload
        ▼
 ┌─────────────────────────── VPS: reminders.etpics.com (Docker) ───────────────────────────┐
 │  Next.js app            Postgres                                                          │
 │   • record UI / PWA       • Capture (audio + images + transcript + status)                │
 │   • custom views          • Reminder / CalendarEvent / Thought  (mirror of Apple + new)   │
 │   • agent API (bearer)    • ReminderList, AgentRun                                         │
 │   • media on disk (not world-readable), served via authed /api/audio|image/:id            │
 └──────┬──────────────────────────────────────────────────────────────────────────▲────────┘
        │ on new capture (and on "Run AI pass" button):                             │
        │ SSH push  →  run-agent.sh <captureId>     POST /api/agent/result ─────────┘
        │ (eorganize-style: VPS holds ssh key,      POST /api/agent/mirror
        │  reaches Mac at host:2222)                (created externalIds; Apple→app upsert)
        ▼
 ┌────────────────────────────── Home Mac agent (invoked on demand) ─────────────────────────┐
 │  1. fetch the named capture (audio + images) from VPS over authed URLs                     │
 │  2. whisper.cpp → transcript                                                               │
 │  3. claude -p (headless) routes transcript + images → {reminders[], events[], thoughts[]}  │
 │  4. write via EventKit:  reminders-cli (reminders)  +  etk EventKit helper (cal)           │
 │  5. POST results + externalIds back; mark capture DONE                                     │
 │  6. on a "mirror" invocation: read all Apple reminders+events → upsert into app DB         │
 └────────────────────────────────────────────────────────────────────────────────────────-┘
```

**Trigger = VPS pushes to the Mac (eorganize style), no polling.** When a capture is saved,
the Next.js server fires a fire-and-forget SSH to the home Mac (`ssh mac 'run-agent.sh
<captureId>'`), exactly like eorganize's "Start AI pass" — except automatic on upload so it's
hands-off. The VPS app container holds a dedicated ssh key + known_hosts (reuse the existing
eorganize connectivity to the home Mac at `:2222`). The Mac needs no inbound web exposure and
isn't constantly waking to poll. A **manual "Run AI pass" button** re-triggers a stuck capture,
and a **"Sync from Apple" button** (plus an optional VPS cron that SSHes on a schedule) fires
the mirror pass. The agent posts results back via the bearer-token API + a callback secret,
same as eorganize.

---

## 3. Data model (Prisma sketch)

```prisma
model User {            // single user for now, but real auth (public internet)
  id           String   @id @default(uuid())
  email        String   @unique
  passwordHash String
  createdAt    DateTime @default(now())
}

model Capture {                         // one raw recording (audio optional, images optional)
  id          String        @id @default(uuid())
  audioPath   String?                   // /app/uploads/YYYY/MM/<uuid>.webm (null = image/text only)
  audioMime   String?
  durationMs  Int?
  textNote    String?                   // optional typed note alongside audio/images
  transcript  String?
  status      CaptureStatus @default(PENDING)   // PENDING→PROCESSING→DONE / FAILED
  routing     Json?                     // raw AI decision for audit/replay
  error       String?
  createdAt   DateTime      @default(now())
  processedAt DateTime?
  images      Attachment[]
  reminders   Reminder[]
  events      CalendarEvent[]
  thoughts    Thought[]
}

model Attachment {                      // image attached to a capture (or kept on a thought)
  id         String   @id @default(uuid())
  capture    Capture? @relation(fields: [captureId], references: [id])
  captureId  String?
  thought    Thought? @relation(fields: [thoughtId], references: [id])
  thoughtId  String?
  path       String                     // full WebP, 1200px long edge (sharp), like eorganize
  thumbPath  String                     // 400px square WebP thumbnail
  width      Int?
  height     Int?
  createdAt  DateTime @default(now())
}

model ReminderList {                    // mirror of Apple lists
  id         String     @id @default(uuid())
  externalId String     @unique         // EventKit calendarIdentifier
  name       String
  color      String?
  reminders  Reminder[]
}

model Reminder {
  id           String        @id @default(uuid())
  externalId   String?       @unique     // EventKit calendarItemIdentifier (null until agent creates it)
  list         ReminderList? @relation(fields: [listId], references: [id])
  listId       String?
  title        String
  notes        String?
  dueDate      DateTime?
  priority     Int?                       // 0 none, 1 high … (maps to Apple 1/5/9)
  completed    Boolean       @default(false)
  flagged      Boolean       @default(false)
  source       Capture?      @relation(fields: [sourceId], references: [id])
  sourceId     String?
  syncState    SyncState     @default(SYNCED) // PENDING_PUSH | SYNCED | CONFLICT
  lastSyncedAt DateTime?
  createdAt    DateTime      @default(now())
}

model CalendarEvent {                    // phase 2; same shape as Reminder
  id           String    @id @default(uuid())
  externalId   String?   @unique
  calendar     String?
  title        String
  notes        String?
  location     String?
  startsAt     DateTime
  endsAt       DateTime?
  allDay       Boolean   @default(false)
  source       Capture?  @relation(fields: [sourceId], references: [id])
  sourceId     String?
  syncState    SyncState @default(SYNCED)
  lastSyncedAt DateTime?
}

model Thought {                          // the "misc thoughts" / assistant memory
  id        String       @id @default(uuid())
  body      String
  tags      String[]
  pinned    Boolean      @default(false)
  archived  Boolean      @default(false)
  source    Capture?     @relation(fields: [sourceId], references: [id])
  sourceId  String?
  images    Attachment[]               // images can ride along on a logged thought
  createdAt DateTime     @default(now())
}

model AgentRun {                         // audit of each agent pass (like eorganize AIReviewSession)
  id          String   @id @default(uuid())
  kind        String                     // "process" | "mirror"
  startedAt   DateTime @default(now())
  finishedAt  DateTime?
  status      String                     // RUNNING | SUCCEEDED | FAILED
  counts      Json?                      // {captures, reminders, events, thoughts, mirrored}
  log         String?
}

enum CaptureStatus { PENDING PROCESSING DONE FAILED }
enum SyncState { PENDING_PUSH SYNCED CONFLICT }
```

**Sync direction (v1):** Apple is the source of truth for reminders/events. The mirror pass
upserts Apple → app by `externalId`. New captures create Apple items (app → Apple), then the
mirror reconciles. Edits *in the web UI* are out of scope for v1 (view + capture only); when
we add them they'll set `syncState=PENDING_PUSH` and the agent applies them. This avoids
conflict-resolution complexity on day one.

---

## 4. The capture → action pipeline (the heart of it)

1. **Record (browser).** Reuse eorganize's `MediaRecorder` flow: prefer
   `audio/webm;codecs=opus`, fall back to `audio/mp4` (iOS Safari). Optionally attach one or
   more **images** (camera or library) and/or a typed note in the same capture. Server
   encodes images to WebP + thumbnail via `sharp` (eorganize's recipe). Upload as a `Capture`,
   status `PENDING`. UI shows it in a "processing" state. (Audio is optional — an image + note
   alone is a valid capture.)
2. **Trigger (VPS → Mac).** On capture save, the server SSHes the home Mac and runs
   `run-agent.sh <captureId>` (eorganize-style push, automatic). The agent fetches that
   capture's audio **and image** URLs (authed). No polling.
3. **Transcribe (Mac).** If audio present: download → `ffmpeg` to 16k mono wav →
   `whisper-cli -m ggml-base.en.bin --no-timestamps`. (Identical to eorganize's recipe.)
4. **Route (Mac, Claude Code headless).** Download any images locally, then `claude -p
   "<routing prompt>"` with the transcript, the typed note, the **image file paths** (Claude
   reads them as visual context), and the current date/time/timezone. Claude returns strict JSON:
   ```json
   {
     "reminders": [{"title":"…","list":"…","dueDate":"2026-06-02T17:00:00","priority":1,"notes":"…"}],
     "events":    [{"title":"…","startsAt":"…","endsAt":"…","allDay":false,"location":"…","notes":"…"}],
     "thoughts":  [{"body":"…","tags":["…"]}],
     "summary": "one line of what I did"
   }
   ```
   The prompt instructs: reminders for to-dos, events for things with a specific time/place,
   thoughts for everything else (ideas, observations, journal). Use attached images as
   context — read dates/names/details off a flyer, receipt, or whiteboard, and attach the
   image to the resulting thought when it's reference material. Resolve relative dates
   ("next Tuesday", "in two weeks") against the supplied now. When unsure between reminder and
   thought, prefer thought (low-risk, nothing clutters Reminders).
5. **Write to Apple (Mac).**
   - Reminders: `reminders add "<list>" "<title>" --due-date "…"` (keith/reminders-cli).
   - Calendar events: a tiny **Swift EventKit helper** (`etk`) we ship in the repo — most
     robust for create + read of both reminders and events. Fallback for v1 if we want zero
     compiled code: `osascript` against Calendar.app. (Recommend the Swift helper; ~120 lines,
     one `swiftc` build, no runtime cost.)
   - Thoughts: no Apple write — just stored in the app DB.
6. **Report back.** `POST /api/agent/result` with transcript, the created `externalId`s, the
   `routing` JSON, and a summary. App marks the capture `DONE` and links the new rows.
7. **Mirror pass (same agent, every few min).** `etk` dumps all reminders + upcoming events as
   JSON → `POST /api/agent/mirror` → upsert by `externalId`, soft-delete rows whose
   `externalId` vanished. This is what populates the custom views with your *existing* Apple
   reminders, not just ones created through the app.

**Idempotency:** captures carry a status; the agent claims a capture (`PENDING→PROCESSING`)
before working so two poll cycles can't double-create. `externalId` unique constraint makes
the mirror upsert safe to re-run.

---

## 5. API surface (bearer-token, like quail)

Agent endpoints (header `Authorization: Bearer $AGENT_TOKEN`) — the Mac is *invoked* by the
VPS over SSH, then calls these to fetch input and report results:

- `GET  /api/agent/capture/:id` → `{captureId, audioUrl, imageUrls[], textNote, createdAt}`
- `POST /api/agent/result` `{captureId, transcript, routing, reminders[], events[], thoughts[], summary}`
  (also flips the capture PROCESSING→DONE)
- `POST /api/agent/mirror` `{lists[], reminders[], events[]}` → full upsert+reconcile

The VPS→Mac trigger itself is SSH (`run-agent.sh <captureId>` / `run-agent.sh --mirror`), not
an HTTP endpoint — the Mac exposes nothing inbound.

User-facing (JWT cookie):

- `POST /api/captures` (multipart: audio and/or images and/or typed note)
- `GET  /api/audio/:id` (authed audio playback)
- `GET  /api/image/:id` (authed full image), `/api/image/:id/thumb` (thumbnail)
- `GET  /api/reminders`, `/api/events`, `/api/thoughts` (+ query/filter for custom views)
- `POST /api/thoughts` (type a thought directly, no audio)

---

## 6. Web UI (PWA)

- **Installable PWA** (manifest + service worker) so it sits on your home screen and opens to
  the dashboard. iOS Safari supports `MediaRecorder` (14.3+); we keep the audio path tolerant
  of `audio/mp4`.
- **Home = Dashboard/overview** (the landing page). The big record button sits at the top
  (with "attach photo" camera/library + an optional text field, so a capture can be voice,
  image, text, or any mix). Below it, an at-a-glance overview of relevant stuff:
  - **Today** — reminders due today, overdue, and flagged (across all lists).
  - **Up next** — the next few calendar events.
  - **Recent captures** — what was recorded and what each became, with processing status + thumbnails.
  - **Latest thoughts** — most recent logged thoughts, pinned ones surfaced.
  - **Quick stats** — open reminders by list, anything stuck in "processing".
- **Reminders view:** the custom angle — group by list / due-today / overdue / flagged, the
  views Apple's app doesn't give you. Read-only mirror in v1.
- **Calendar view:** upcoming events (phase 2).
- **Thoughts:** searchable, taggable log — your assistant memory. Pin/archive.
- Auth: login page, scrypt + JWT, same as eorganize. **(Currently BYPASS_AUTH=true — login
  temporarily skipped during build; flip to false before it holds real data.)**

---

## 7. Hosting & deploy (copy the house template)

- `docker-compose.prod.yml`: `db` (postgres:16-alpine), `app` (Next.js standalone),
  `nginx` (alpine) publishing `8543:443` + `8444:80`, cert volume
  `/var/cpanel/ssl/apache_tls/reminders.etpics.com:/etc/ssl/reminders.etpics.com:ro`,
  network `reminder_net_prod`, named volume for `db` and an `uploads` volume for audio.
- `nginx/nginx.conf`: SSL on 443, `client_max_body_size 50m` (audio uploads),
  `proxy_pass http://reminder-app:3000`, X-Forwarded-Proto https.
- `deploy.sh`: rsync (exclude node_modules/.next/.git/.env/uploads) →
  `docker compose -p reminder_prod -f docker-compose.prod.yml up -d --build` →
  `prisma migrate deploy`. First run generates `.env` with random `SESSION_SECRET`,
  `DB_PASSWORD`, and **`AGENT_TOKEN`**.
- **Manual one-time:** cPanel Apache vhost for `reminders.etpics.com` → `127.0.0.1:8543`,
  let cPanel issue the SSL cert. (Same step every other app needed.)

### Home-Mac agent install
- `brew install keith/formulae/reminders-cli` (only missing piece — `whisper-cli`, `claude`,
  `osascript`, `shortcuts`, `ffmpeg` already present).
- Build `etk` Swift helper once (`swiftc agent/etk.swift -o etk`). **Reads/writes go through
  `etk` (EventKit), NOT AppleScript** — verified that AppleScript `whose` filters time out on
  large lists (Shopping = 584 items), while EventKit handles filtering + scale reliably.
- Drop `run-agent.sh` on the Mac (transcribe → claude route → write to Apple via `etk` → POST back).

**TCC / GUI-session handoff (important — verified 2026-05-30).** Reminders + Calendar access
is granted to the *logged-in GUI session*, not to SSH sessions. A process invoked directly
over SSH **cannot** touch EventKit (it times out / is denied and can't show a prompt). So:
- The actual EventKit work runs as a **LaunchAgent in the GUI (Aqua) session** that holds the
  Reminders/Calendar grant — NOT inside the SSH session.
- The **SSH push only *signals*** that agent (e.g. write a job file it watches, or
  `launchctl kickstart -k gui/$UID/<label>`); the LaunchAgent then does transcribe + EventKit
  + POST-back. The VPS still triggers the work; it just hands off to the permitted process.
- One-time: run the agent once interactively from the GUI session to approve the Reminders
  and Calendar prompts (already approved for the Terminal session during testing).

- **SSH key (eorganize-style):** generate a dedicated keypair; public key in the Mac's
  `authorized_keys`, private key mounted read-only into the VPS app container (e.g.
  `/etc/reminder/agent_to_mac`) with a pinned `known_hosts`. Reuse the existing reachability
  to the home Mac at `:2222`. The server invokes `ssh -i … mac '<signal the LaunchAgent>'`.
- The LaunchAgent runs in the GUI session; no constant poll loop. (Optionally a VPS cron SSHes
  a `--mirror` signal every N minutes to keep the mirror fresh.)
- Agent config on the Mac: `REMINDER_API=https://reminders.etpics.com`, `AGENT_TOKEN=…`,
  callback secret, whisper model path.

---

## 8. Security notes

- Audio is **not world-readable** (unlike a naive static dir) — served only through authed
  `/api/audio/:id`, mirroring eorganize's handling.
- `AGENT_TOKEN` is a long random secret, separate from the user session secret; agent
  endpoints accept *only* the bearer token, never a cookie, and are rate-limited.
- Single login; JWT in httpOnly secure cookie. Consider IP allowlist later if you want.
- The Mac agent holds only the bearer token + its EventKit grant; no DB creds leave the VPS.
- The VPS holds a **dedicated SSH key** to the Mac (separate from any personal key), ideally
  restricted in `authorized_keys` to running `run-agent.sh` (forced command), so a VPS
  compromise can't get a general shell on the Mac.

---

## 9. Build milestones

1. **Scaffold + deploy skeleton.** Repo, Prisma schema, Docker/nginx/deploy.sh on 8543,
   auth, empty record page. Get `https://reminders.etpics.com` up with login. *(Proves hosting.)*
2. **Capture + trigger, no AI.** Record / attach image / type → upload → store → list captures
   with status and thumbnails. VPS→Mac SSH trigger + `run-agent.sh` stub that just transcribes
   and posts the transcript back. *(Proves audio + images + the Mac trigger link.)*
3. **AI routing — reminders + calendar together** (with correction baked in). Claude-Code
   routing prompt (uses images as context) → reminders + calendar events created via the `etk`
   EventKit helper; thoughts stored app-side; results posted back. Runs in a **GUI-session
   LaunchAgent** (SSH only enqueues — SSH sessions can't hold the EventKit TCC grant).
   *(Proves the core value, incl. appointments.)*
4. **Mirror pass.** `etk` reads all Apple reminders + events → custom views populated.
5. **Thoughts UX.** Search/tags/pin; direct typed thoughts; image attachments shown.
6. **Polish.** PWA manifest/SW, install on phone, overdue/flagged + upcoming smart views,
   edit-in-app (write-back) if wanted.

Each milestone is independently deployable and useful.

### Transcription correction (designed into M3, since re-process == routing)
A garbage transcript → confidently-wrong item is the main failure mode. Approach: **don't gate
captures behind review** (keeps "record & walk away"), make correction fast afterward:
- **Audio is ground truth, always kept.** Capture detail view replays audio next to transcript + items.
- **Two fix levels:** small error → edit the resulting reminder/event/thought directly (writes
  back to Apple via `etk`). Totally off → edit the transcript text + **Re-process**.
- **Idempotent re-process:** routing keyed on `sourceId` + item `externalId`, so re-running
  updates the linked Apple item in place (or deletes + recreates) — never duplicates.
- **Reduce errors at source:** feed whisper a `--prompt` vocabulary built from the user's list +
  calendar names (and custom terms) so proper nouns ("Luxer", "Coturnix", family names) transcribe right.
- **Fail safe (fast-follow):** on low whisper confidence, flag "needs review" instead of
  auto-creating; calendar events get a confirm when confidence is low (wrong time > wrong to-do).

### Execution model (M3+): GUI-session LaunchAgent, SSH enqueues
- SSH-invoked `run-agent.sh` only **enqueues** the captureId (drops a job file in a watched dir).
- A **WatchPaths LaunchAgent** in the logged-in GUI (Aqua) session runs `process-capture.sh`:
  whisper → claude route → `etk` (EventKit, needs the GUI-session TCC grant) → POST back.
- This honors "the app tells the Mac to go" (SSH push) while running the EventKit work where the
  permission actually lives. `etk` granted Reminders+Calendar once interactively.

---

## 10. Open questions for Eric (non-blocking)

- **Which Apple lists/calendars** should be in scope to mirror — all, or a subset (e.g. exclude
  shared shopping lists, work calendars)?
- **`etk` Swift helper vs osascript** for calendar (recommend the helper; tiny, robust). OK to
  add a ~120-line Swift file to the repo?
- Default Reminders **list / calendar** when Claude can't infer one (e.g. an "Inbox" list)?
- Should thoughts ever **also** create an Apple reminder/note, or stay app-only? (v1: app-only.)
- Reuse the **existing eorganize SSH path** to the home Mac (`:2222`) for the trigger, or set up
  a fresh dedicated key for reminder? (Recommend a fresh dedicated key, forced-command locked.)

---

## 11. Household accounts (multi-user)

Added 2026-09-22. Goal: Eric's wife and kids each get their own login, sharing lists and a
Focus board, with the full voice-capture experience — but **nothing that reaches outside
Steward**.

### Shape: one household, not multi-tenancy

No tenant/household table. Every root-owned model gains `ownerId`; `ChecklistItem` and
`Attachment` inherit through their parent rather than carrying a second copy of the truth.
Owned models: `Capture`, `Reminder`, `ReminderList`, `CalendarEvent`, `Thought`, `Checklist`,
`FocusPin`, `Topic`, `Activity`.

`visibility: PRIVATE | HOUSEHOLD` on `Checklist`, `Reminder`, `Thought`. Lists default to
HOUSEHOLD (the shared-list case Eric asked for), everything else defaults to PRIVATE.
**Captures are unconditionally private** — a raw voice memo is the rawest thing in the app and
never becomes visible to another account.

### Focus: personal board + shared section

`FocusPin.scope: PERSONAL | SHARED`. A board renders SHARED pins (any owner) above the
viewer's own PERSONAL pins. Invariant: **a pin may only be SHARED if its target record is
HOUSEHOLD-visible**, so the "pin to family" action shares the underlying item in the same
gesture and there is no way to expose a private record through a pin. Anyone may unpin from
the shared section — it is a shared board.

⚠️ `FocusPin` currently has `@unique` on `reminderId` / `checklistId` / `checklistItemId` —
**one pin per item globally**. Left alone, whoever pins the grocery list first locks everyone
else out. These become composite uniques keyed on owner; note that Postgres treats NULLs in a
composite unique as distinct, so the three nullable target columns need care.

### Capability gating: voice capture stops at Steward's edge

The agent's result payload carries five keys that POST to *other services*: `chores`, `points`,
`timers` (→ chores app) and `sales`, `expenses` (→ FlockBase). Everything else (`reminders`,
`events`, `thoughts`, `refinedThought`, `checklists`, `checklistAdd`, `focus`, `completed`,
`updated`, `deleted`) is internal to Steward.

**That split is the gate.** Non-Eric accounts get every internal capability and none of the
external ones. Notes, lists, reminders and calendar all work exactly as they do today; a kid
cannot create a chore, award points, start a timer on someone's device, or book a farm sale.

`User.capabilities String[] @default([])` — **fail-closed**: an account has an extra only if
explicitly granted. The backfill grants Eric all five.

Enforced in **two layers, and the server layer is the one that counts**:

1. **`/api/agent/result` drops gated arrays** based on `capture.ownerId`'s capabilities, before
   any outbound fetch. This is authoritative. The router is an LLM reading whatever was spoken
   into it, so a persuasive voice memo ("add a sale for $500") must not be able to talk its way
   past the boundary — the prompt is not a security control.
2. **`/api/agent/capture/[id]` tailors the prompt** so the agent doesn't try in the first place:
   skip the `fetchChoresKids()` / `fetchChoresTags()` lookups for accounts without the
   capability (also saves two external calls per capture) and omit those tools from the routing
   instructions.

A dropped item is **surfaced, not silently swallowed** — it lands in the existing `fwdFailures`
channel so the capture detail reads "chores aren't enabled for this account" rather than the
item quietly vanishing.

### Apple sync stays single-account

EventKit runs on etminim1 under Eric's iCloud, so **only Eric gets Apple mirroring**; everyone
else's reminders are app-only. This is a real limitation, not an implementation gap.

⚠️ `/api/agent/mirror` full-reconciles: it deletes mirrored rows no longer present in EventKit.
That is correct today because every row is Eric's. The moment a second account creates a
reminder, the next mirror run reaps it. `mirror` and `pending` must both be pinned to
`APPLE_OWNER_USER_ID` **before** any second account exists. This is the one step in the whole
migration that can destroy data.

### Other single-user assumptions to close

- **Media authz.** `/api/image/[id]` and `/api/audio/[id]` check the session but not ownership —
  with five accounts that is "any family member can read any photo or voice memo by id".
- **Admin-only surfaces.** `/trades`, `/health`, `/api/monitor/*`, `/api/organize`, `/api/sync`
  need a `User.isAdmin` gate.
- **The organize pass** writes a global `Topic` table keyed by unique name; unscoped it blends
  everyone's thoughts into shared AI summaries. Must run per-owner.
- **Session length.** The cookie is 400 days — right on a personal phone, wrong on a shared
  iPad. Needs a visible account switcher / logout.
- **Queue contention.** All captures serialize through one Claude session on the mini. Fine at
  family scale; simultaneous recordings queue rather than fail.

### Order of work

Steps 1–5 ship while Eric is still the only account, so each is verifiable against real data
with no possibility of a family member seeing something they shouldn't.

1. ~~**Backup + branch.**~~ **Done 2026-09-22.** Nightly backup verified; a fresh pre-migration
   dump kept on the VPS (`/root/pre-multiuser-*.sql.gz`) and off it.
2. ~~**Schema + backfill.**~~ **Done 2026-09-22** (`20260922_household_accounts`). All 2069 rows
   owned, `FocusPin` uniques reworked, `isAdmin`/`capabilities` added and granted to the
   founding account. Rehearsed against a restore of the prod dump: no schema drift, and two
   accounts verified able to pin the same item.
3. ~~**`src/lib/scope.ts` + scope the session routes.**~~ **Done 2026-09-22.** All 20
   session-backed routes read through one module; by-id lookups moved to `findFirst` so an
   unowned id is a 404. Media authz (`/api/image`, `/api/audio`) came along with it — it is
   read-scoping. Verified by running the real build against a restore of prod and driving two
   accounts over HTTP: the second sees 0/0/0/0/0 against the founder's 52/15/30/25/300, and
   every direct-id attempt 404s.
4. ~~**Pin the Apple mirror** to `APPLE_OWNER_USER_ID`.~~ **Done 2026-09-22.** `/api/agent/
   mirror` resolves an explicit owner and its reconcile `deleteMany` is scoped (verified live:
   first post-migration run reported `removed: 0`); `/api/agent/pending` is scoped the same way,
   so another member's reminders are never offered to the Apple push; and
   `APPLE_OWNER_USER_ID` is set in prod rather than resolving by fallback.
5. ~~**Close the gaps.**~~ **Done 2026-10-09.** Media ownership shipped with step 3; `isAdmin`
   now gates `/api/monitor*`, `/api/trades*`, `/api/trades-command`, plus `/api/sync` and
   `/api/organize` (admin OR agent bearer, so the crons still run), and the `/trades` +
   `/health` pages redirect a non-admin home.
   **Per-owner organize** closed it out on 2026-10-09: all three organize endpoints
   (`/api/agent/thoughts`, `/reminders`, `/checklists`) now return `owners[]` buckets, and
   `organize.sh` runs one Claude call and one POST per bucket with its `ownerId`. The server
   pins every write to that owner (`updateMany` on `{ id, ownerId }`, checklist items through
   `{ list: { ownerId } }`), so a crossed id writes nothing instead of retopicing someone
   else's row; `Topic` upserts and the activity-feed entry land on the pass's own account.
   An omitted `ownerId` still falls back to the founding account, so the app can deploy ahead
   of the agent. Verified against a throwaway Postgres with two seeded accounts: buckets split
   correctly, every cross-owner write returned `updated: 0`, an unknown `ownerId` 400s, and
   each account ended up with its own topics and its own activity rows.
6. ~~**Capability gating.**~~ **Done 2026-09-22.** `/api/agent/result` runs all five outbound
   keys through `gateCapability()` against the capture owner's `User.capabilities` before any
   external fetch; internal routing is untouched. `/api/agent/capture/:id` tailors the prompt
   (skipping the chores-roster, tag and expense-category lookups) and — a leak found while in
   there — now scopes the router's list-name context to the capture owner, where it previously
   handed over every household member's active list names. Verified by posting an identical
   payload as two accounts: only the capable one forwarded, and the other's capture detail
   lists all five drops as "not enabled for this account".
7. ~~**UI.**~~ **Done 2026-10-09.** List visibility toggle (owner-only share/private control on
   the list page, shared badge on the dashboard card); shared/personal Focus sections (the pin's
   share button creates a SHARED pin and shares the underlying item in the same gesture, so the
   SHARED-implies-household invariant holds); and the account menu — tap your own name in the
   dashboard header for the signed-in email and **Sign out**.
   Sign-out wipes what the device was holding for that account before dropping the cookie: the
   Web Push subscription (otherwise the next person on a shared iPad keeps getting the previous
   one's notifications), the IndexedDB thoughts cache (which renders instantly on the next
   visit, before any fetch can replace it) and the service worker's response cache. Each step is
   best-effort — a device that cannot clear its cache must still be able to sign out.
8. **Create accounts** one at a time, wife first. Users come from `SEED_USERS` at deploy; for
   four or five people that stays simpler than a signup flow. Note the seed only ever CREATES —
   changing a password in `SEED_USERS` later does nothing, so password changes are a manual DB
   update until there is a UI for it.

### Existing data: what the migrations decided

- All pre-existing rows belong to the founding account.
- All 20 pre-existing lists were set PRIVATE (`20260922_existing_lists_private`). New lists
  still default to HOUSEHOLD. Share the ones the family should see from the list page.
- Reminders and thoughts were already PRIVATE by default, so nothing there is exposed.
