# dailysplice — agents.md

Project constitution. Inherits from `/srv/CLAUDE.md` (server scope) and
`/srv/apps/CLAUDE.md` (tenant scope). Only rules meeting the admission rubric
(failure-backed, tool-enforceable, decision-encoding, or triggerable) belong
here — no generic advice a model already applies. Budget: ~200 lines.

Model-independence: this file is the source of truth. `CLAUDE.md` is a symlink
to it so Claude Code loads the same content.

## Read first (in order)
1. **`brief.md`** — what this project is, and the stack decision.
2. **`.logs/planning/dailysplice-2026.md`** — the build plan: locked decisions,
   milestones, gates, the binding out-of-scope list.
3. **`/srv/projects/standards/README.md`** — server-wide coding standards. The
   stack is decided, so the dispatch is `coding.md` → `react.md` →
   `frontend.md`. All three apply.
4. **`.logs/handoff.md`** — newest-first; where the last session left off.

## Project-specific rules

- **Signing in is not the same as being admitted. Invite-only is enforced by
  THIS app, never by the domain gate.** The host is PUBLIC (Pattern B requires
  it — see the next rule), so *any* of the server's BW accounts can complete the
  OAuth handshake. Admission is a separate, default-deny decision made in
  `app/services/identity.py` against the invite allowlist: an identity with no
  invite gets a `role='none'` account row and reaches nothing but the "not
  invited" screen. Do not conflate `authenticated` with `authorized`, and do not
  push admission into Caddy or id-auth — a Pattern B app owns its authorization.

- **The product idea is not public. Nothing anonymous describes what the app
  does.** Decided 2026-08-05. The landing page is a wordmark, "Welcome to
  DailySplice", and a Sign in button; `index.html`'s `<meta name="description">`
  is deliberately contentless. Writing a nice explanatory hero is the natural
  instinct and it is wrong here — that string is what search engines index and
  what link previews show.

  **"Behind sign-in" does NOT mean "not published".** This is a single-bundle
  SPA: every string in the signed-in UI — dashboard copy, empty states, button
  labels — is compiled into the one JS file that every anonymous visitor
  downloads. Sign-in gates the *data*, never the *copy*. So treat any sentence
  you put in a component as public writing. Today the bundle reveals that
  DailySplice builds Spotify playlists and is invite-only; it does **not**
  reveal the distinctive idea (splicing user-made podcast episodes into the
  mix), and that line is the one to hold. If genuinely secret copy is ever
  needed, it has to come from a gated API response, not from a component —
  code-splitting does not help, because the chunk is fetchable too. Verify with:
  ```
  curl -s https://dailysplice.com/assets/index-*.js | grep -c "<phrase>"
  ```

- **No Spotify type, field, or endpoint leaks past `app/services/spotify/`.**
  The splice engine, the episode model and the taste model take and return this
  app's own types, never Spotify payloads. This is not tidiness: Spotify's Web
  API is closed to new public apps (plan §3b — 5 users, and extended access
  needs a registered business with 250k MAU), so the platform this product ships
  on is an open question. Keeping Spotify behind one package is what makes
  adding Apple Music or YouTube Music a new implementation rather than a
  rewrite. Do **not** pre-build a provider abstraction on spec — just never
  write the coupling that would make one expensive.

- **Never gate this host.** `dailysplice.com` must stay `auth_enabled:false`.
  id-auth's gate snippet reverse-proxies `/login`, `/logout` and `/auth/*` to
  id-auth — the exact paths this app owns — so gating it 404s sign-in. To hide
  the app before launch use `srv-gw id-site-set --host dailysplice.com
  --lan-only true`, **never** a password gate. Recovery if it is ever found
  gated: `srv-gw id-gate --site dailysplice.com --mode public`.

- **Do not register (or re-register) the BW app-client before the app's own
  login is deployed and working.** `srv-gw app-client-register` **auto-ungates
  the host**, so registering early leaves the site public with no app login
  drawn. Order is: build → deploy → register → verify.

- **If a service worker is ever added, the auth routes MUST be denylisted from
  its navigation fallback** (`navigateFallbackDenylist: [/^\/login/, /^\/logout/,
  /^\/auth\//, /^\/api\//]`). Without it the SW answers `/auth/callback` from the
  cached shell, the request never reaches the backend, and **sign-in fails
  silently with zero server-side evidence**. This app ships no service worker
  today precisely to avoid that class of failure; adding one is a deliberate
  decision that carries this requirement with it. (Cost a full debug cycle on
  garden2 — its `2.0.0-dev.15` changelog entry.)

- **Code lives in `main/`; runtime state does not.** `main/` is code only (v8
  §3.2). The compose file, `.env`, `.app.env` and `.bw-auth.env` sit at the
  workspace root because `srv-gw deploy` requires the compose file there — a
  documented deviation that T3.1 retires, not an accident.

- **`srv-gw fix-permissions` re-widens `.bw-auth.env` — re-tighten it after every
  run.** The gateway writes that file `600 srv-gateway` (the form PATTERN-B
  requires for a client secret), but `fix-permissions` relaxes it to `660` group
  `dailysplice-dev` and adds a matching ACL. That group contains `podman-dev`
  (a retired service account) and would contain **every developer ever granted
  access to this project** — all of whom would then be able to read the
  credential that mints this app's identity tokens. It is not a loud failure;
  nothing breaks, the blast radius just quietly grows. Restore with:
  ```
  rm .bw-auth.env
  srv-gw app-client-rotate-secret --client-id dailysplice \
      --secret-out /srv/apps/dailysplice/.bw-auth.env
  srv-gw deploy --project dailysplice
  ```
  Rotating rather than re-chmodding is deliberate: the old secret was briefly
  group-readable, so it is retired rather than re-protected. Confirm with
  `ls -l .bw-auth.env` → `-rw------- srv-gateway`, and that `cat` fails as rian.

- **Create new subdirectories under `sg dailysplice-dev`.** If the session's
  supplementary groups don't include `dailysplice-dev`, the kernel **silently
  strips setgid** on `chmod 2775` — chmod reports success while `stat` still
  shows `775`, and every file created underneath lands with the wrong group.
  `srv-gw fix-permissions` fixes the project root but does not recurse setgid
  into new subdirectories.

## Operational
- **Type:** custom, deployed via the gateway. Port `172.17.0.1:3141`.
  Domain `dailysplice.com` (+ `www.` redirect), **un-gated / PUBLIC** by design.
- **Manage:** `srv-gw {deploy,restart,logs,status} --project dailysplice`.
- **Deploy semantics:** this stack has a Docker build step, so deploys are
  `srv-gw deploy --project dailysplice --build` (a couple of minutes). Plain
  `srv-gw restart` only restarts the existing image — it will not pick up code
  changes.
- **Deploy confirmation:** `main/VERSION` is surfaced at `/api/version` and in
  the UI footer. Every change bumps it and adds a newest-first
  `main/CHANGELOG.md` line. The visible version is how you confirm a deploy
  actually landed — there is no built-vs-deployed drift check for tenant apps.
- **Database:** Postgres 17 sidecar, no published port. Reach it with
  `docker exec -it dailysplice-db psql -U dailysplice -d dailysplice`.
  Every schema change is an Alembic migration.
- **Write access:** `dailysplice-dev` Unix group (setgid + default ACL). New
  files need `chmod 664` / dirs `775`, or `srv-gw fix-permissions --project
  dailysplice`, so the service user can read them.

## Layout
```
/srv/apps/dailysplice/         # workspace — docs and runtime state
├── brief.md  agents.md  CLAUDE.md -> agents.md
├── .logs/{planning/, handoff.md, diary.md, v8-feedback.md}
├── .memory/  .archive/  notes/
├── docker-compose.yml         # app + postgres sidecar; build context ./main
├── .env                       # gateway-managed (PROJECT_NAME, PORT) — do not edit
├── .app.env                   # app secrets (DB password, session secret)
├── .bw-auth.env               # BW client id/secret, written by the gateway
└── main/                      # the instance — code only
    ├── VERSION  CHANGELOG.md  Dockerfile  entrypoint.sh
    ├── alembic/  app/{routers,services,models}/  frontend/  scripts/
```
