# dailysplice — diary

> Session-by-session log: date, what changed, what was verified. Append-only,
> newest first. This is *state*, not summaries — no generated codebase
> overviews (v8 auto-generated-context law).

---

## 2026-08-05 — landing page stripped; Spotify connect flow (v0.2.0)

**Changed.** Public landing page reduced to wordmark + "Welcome to DailySplice"
+ Sign in, and the `<meta name="description">` pitch removed — rian does not
want the idea public yet. Built the Spotify connect flow: per-user OAuth
authorization-code with `state` anti-CSRF, Fernet-encrypted token storage
(`services/crypto.py`, fails closed with no key), refresh-on-demand with a 90s
margin, `spotify_accounts` table (Alembic `0002`, PK = user id, CASCADE), and a
dashboard card covering not-configured / not-connected / connected / revoked.
`/spotify/*` joined `/api/*` behind the default-deny middleware; gated *browser*
paths now redirect instead of returning JSON to a navigation.

**Researched, and it changed the plan.** Checked Spotify's live policy rather
than working from memory — which was right, because it has moved twice since the
idea was sketched. Development Mode is **5 users** (was 25), hand-allowlisted,
and the app owner must hold Premium. Extended quota now needs a **registered
business + 250,000 MAU**, individuals barred — circular, so "a public Connect to
Spotify button" is not reachable. New apps also lost Recommendations and Audio
Features permanently (Nov 2024), and Feb 2026 stripped Development Mode further
(no saved-library read, search capped at 10, playlist `/tracks` → `/items`).
The one piece of good news: playlists **do** accept `spotify:episode:` URIs, so
the splice mechanic works — provided episodes are published on Spotify. Written
up as plan §3b with four options; chose option 1 (five hand-allowlisted users),
which matches invite-only exactly.

**Verified.** `gate-f0.sh` **23/23** through the real domain, including a
database check that no token is stored unencrypted. 53 pytest cases (12 new,
covering the gate on the Spotify routes, fail-closed encryption, tamper
detection, key-rotation consequence, and scope creep). ruff and `tsc` clean.
Migration `0002` applied on deploy; seed stayed idempotent. Confirmed the
product pitch is absent from the public JS bundle.

**Found.** A SPA ships its signed-in copy to anonymous visitors — "behind
sign-in" gates data, not strings. The distinctive idea is not in the bundle, but
the generic half is. Recorded in `agents.md`, since the natural next move
(writing product copy into the dashboard) would publish it.

**Blocked.** Spotify credentials — rian registers the app and fills two
placeholders in `.app.env`. Everything else is deployed.

**Follow-up same day — does an old dormant app change anything?** Checked the
February 2026 grandfathering wording: *"if you already have multiple Client IDs
or more than 5 users, you will retain them."* Grandfathering preserves **state,
not entitlement** — an app created and never used has no users to keep, so it
inherits nothing. Extended-quota grandfathering (May 2025) is a separate thing
and protects only apps *previously granted* extended access; a dev-mode app never
was. The one live possibility with a real payoff: an app predating 2026-11-27 may
have kept **audio-features / recommendations**, which no new app can obtain.
Reporting is inconsistent, so rather than reason about it, wrote
`main/scripts/spotify-probe.py` — client-credentials only, no user login, prints
a capability map and no secrets. Recorded in plan §3b.

**Probe result: the old app has the same reduced surface as a new one** — no
audio-features, no recommendations. So nothing is gained by keeping it.

**But the follow-on question ("then I cannot even order a playlist slow → fast")
turned out to be false, and this is the good news of the day.** Tested the live
replacement ecosystem: **ReccoBeats** returns the *exact* Spotify feature schema
(tempo/energy/valence/danceability/…) **keyed by Spotify track id**, batched 20
per call, free and unauthenticated — 40/40 on the sample. Deezer is a free
auth-free secondary source (ISRC → bpm, 7/12 on a sample); AcousticBrainz is a
frozen 2022 dump; Essentia is the toolkit Spotify itself used. Two traps
recorded: ReccoBeats 403s a default `Python-urllib` User-Agent, and the 40/40
sample was drawn from its own index, so it proves the mechanism and not
coverage. Conclusion written into plan §3b: **the splice engine's musical
intelligence is not gated — only the write into a stranger's Spotify account
is.** Those are separate problems with separate answers.

**Stress-tested the podcast design (plan §3c) — one real showstopper, and the
fix improves the product.** The intended shape was "DailySplice hosts podcasts,
Spotify reads them by RSS". Verified that **Spotify does not let listeners
subscribe to arbitrary or private RSS feeds** — its own paid-podcast private
feeds work only in *other* apps. So per-user episodes could only reach Spotify as
public catalogue shows: "your dad has been listening to…" publicly searchable, no
programmatic show creation, a mass-AI-upload pattern matching exactly what
Spotify's spam enforcement targets, and feed-poll latency that breaks "daily"
anyway. Replacement architecture: **DailySplice becomes its own player**,
interleaving streamed music with private generated speech — no RSS, no catalogue,
no privacy leak, and a DJ that can talk over an intro (a playlist cannot).
Apple Music/MusicKit permits this with no user cap. Flagged the one genuine
unknown: interleaving DRM music with own audio in a seamless queue is undocumented
(browser DRM is finicky; expect two players and a cross-fade) — prototype it
before building on it. The **appreciation splices** ("understanding jazz") turn
out to be nearly unblocked and shippable on Spotify today with zero API access.
Reversed the "no music playback in-app" line in `brief.md`, kept visible with the
reason rather than deleted.

**Platform check (plan §3d) — YouTube is out for the player.** Rian proposed
building the player on Apple Music + YouTube Music, marketing via Spotify
podcasts, growing to 250k MAU and then qualifying for Spotify. Checked YouTube's
API Services Terms directly: three clauses each independently forbid the
architecture — no player other than YouTube's own, no "alternate audio tracks"
applied to content, and no background play. YouTube stays usable for the playlist
model, never the radio model. **Apple Music is therefore the only viable host for
the full product**, i.e. a single point of failure of the same kind Spotify just
demonstrated. Also recorded why the 250k endgame is the weak part of an otherwise
sound plan: at that scale the gate no longer matters, clearing it only confers
eligibility under a discretionary review, and success on Apple Music makes the
app read as a Spotify *competitor* — an inverse relationship. Kept the strategy,
dropped the destination: build on Apple Music because it permits the product, use
Spotify podcasts as free reach, treat Web API access as a lottery ticket nothing
depends on.

## 2026-08-04 — fresh start: v8 workspace + F0 foundation (v0.1.0 → v0.1.1)

**Changed.** Replaced the gateway's static-nginx scaffold (archived to
`.archive/f0-nginx-scaffold/`) with a v8 workspace and a real instance in
`main/`. Wrote `agents.md`, `brief.md`, and the build plan; symlinked
`CLAUDE.md → agents.md`.

Built the app on the house stack (`react.md`): FastAPI + SQLAlchemy 2/Alembic +
Postgres 17 sidecar serving a Vite/React 19 SPA from one Python process, via a
3-stage Docker build (OpenAPI dump → TS client + SPA build → Python runtime;
no Node at runtime, nothing generated committed).

Auth is BW Auth Pattern B in PUBLIC mode — OAuth+PKCE against
`auth.bowden.works`, an app-signed session cookie, client-driven silent SSO
probe, full sign-out. Admission is **separate from authentication**: an invite
allowlist keyed on email, default-deny, decided in `services/identity.py`.
Roles `superadmin` / `user` / `none`, mirrored to the BW hub for display.

**Decided.** No service worker (a Workbox navigation fallback swallows the OAuth
callback — garden2's `2.0.0-dev.15`). No router yet (one screen). DB engine
built lazily so the OpenAPI build stage needs no database. `/api/version` stays
gated; the footer version is baked into the bundle from `main/VERSION`, which is
what proves the *assets* are fresh.

**Verified.**
- `gate-f0.sh` **17/17**, through `https://dailysplice.com` and the bridge.
- **Real sign-in, not simulated:** rian completed the OAuth round-trip; the
  invite matched on email and `users` holds `rian / superadmin`.
- 41 pytest cases on the auth boundary; `ruff check` clean; `tsc -b` clean.
- Gated routes 401 without a session and **503 (fail-closed) when the client
  secret is still a placeholder** — the placeholder-as-absent rule working.
- `dailysplice-db` has no host port binding at all (`{"5432/tcp":null}`).
- `srv-gw security-audit`: **0 critical**, no new real finding for this project.

**Fixed mid-session.** SPA fallback answered HEAD with 405 (FastAPI, unlike
Starlette, does not add HEAD to a GET route) — a public page that 405s every
uptime monitor. Also a `grep -c` bug in my own gate script, where `|| echo 0`
appended a second zero to grep's own "0".

**Substrate friction hit** (three entries in `.logs/v8-feedback.md`):
`srv-gw deploy --build` broken fleet-wide by Compose v2.40 moving `--progress`
to a top-level flag (rian fixed the gateway in a parallel session; re-verified
here); `app-client-register --secret-out` failing *after* creating the client
when the target file is pre-staged; and the security audit's deploy-state drift
check being off by exactly the UTC offset, so it false-positives on every
project.

**Accounts.** BW account `jim` (jsbowden58@gmail.com) created; his invite is
seeded as `user`. He has no password yet — that is rian's interactive step.
