# L'Armada — completed-task archive (structured)

Long-term learning + blog/video source material. Each entry = a finished board task
captured in full (description · done · learnings · deps), appended by the `/pre-compact`
ritual before the task is cleared off the active board. Richer than the terse tracker
snapshot in `board-archive.md`. Newest first.

---

## #85 — Pack-level sample grouping (the grouping above the folder)
- **Description:** The Dirt-Samples FOLDER is a loose convention, not the true grouping —
  `rample*` is one bank sliced A0..S57, `808bd/cy/hc/mc` is one drum machine per voice,
  `*_commodore` is one chip sliced by role. PLN wanted the PACK granularity above the
  folder, so we can browse/aggregate at three grains (pack / folder / sample).
- **Done:** `sample_packs.py` + `sample_packs.json` (commit 96bc7bc), captain's log 016.
  A curated REGISTRY (types: drum_machine / bank / chip / source / genre) + an auto-grouper
  (alpha-prefix-before-digit + underscore head/tail tokens, ≥2 folders) that PROPOSES
  packs the registry missed — it caught `trance_*` and `voices_*`, folded in. Reads the
  already-grounded `sample_families.json` (no audio → fast, CPU-light). 22 packs over
  83/150 grounded folders; rolls per-sample family counts up to each pack.
- **Learnings:** Each grain answers a different question — folder = "what did the pack
  author call this slice?", pack = "where did it come from?", sample = "what does it
  actually sound like?". The signal isn't agreement, it's the MISMATCH: 15 of 22 packs
  span multiple audio families BY DESIGN (rample → 8 families, jungle → 11, one Commodore
  chip → bass/lead/pad/fx/synth). A pack defined by ORIGIN is *expected* to scatter across
  the timbre wheel; that scatter is a story (for Unwrapped viz / vibe-search scoping), not
  an error. A 1-folder "pack" is just a folder within the grounded subset — require ≥2
  (may gain siblings when #84 grounds all 718). Same discipline as the folder: a hint to
  compare against, never a law.
- **Deps:** Builds on the grounded `sample_families.json` (#69 resolver); feeds the
  Unwrapped viz (#64 lineage) and vibe-search. Re-run after #84.

## #65 — Fix serve.py Range broken-pipe crash on audio seek
- **Description:** `armada/serve.py copyfile()` threw `ConnectionResetError`/`BrokenPipeError`
  (Errno 104) when a browser aborts/seeks a Range request mid-stream (e.g. `proxy_take89.mp3`
  in the triangle drawer) — the 206 responses worked but the unhandled exception spammed the
  console.
- **Done:** Wrapped both copy branches of `copyfile()` and guaranteed the file handle closes
  in a `finally`; swallow `BrokenPipeError`/`ConnectionResetError`. Verified: a mid-stream
  abort (`curl --max-time` cut) leaves NO traceback and the server keeps serving (206
  follow-up OK). Commit **d70c3ee**.
- **Learnings:** A client disconnect on `<audio>` scrubbing is *normal*, not a failure —
  swallow it, don't log it. This was the keystone QOL fix: it de-frictions every audition
  task (#35 ear-verify, #43/#44 frag-audition, #20 best-take), so cheap-but-high-leverage.
- **Deps:** Blocked nothing formally; unblocks the ear-gating workflow ergonomically.

## #64 — "Information is Beautiful" dataviz of the generated corpus (EPIC)
- **Description:** Phase-2 infodesign of the tide-table corpus (after data-scientist
  exploration) — explorable insights served to a music aficionado.
- **Done:** `corpus.html` narrative + the **Unwrapped** explorable (vibe-search via CLAP,
  dominant-colour density field, zoom/pan) + landing `index.html` + honest style cloud +
  serve.py `/vibe` warm endpoint. Shipped across the prior session's commits; vibe-search,
  field-tuning, label-completion (1490/1501 samples), and zoom all landed.
- **Learnings:** Dots are the stars — the field stays subtle; **don't average hues** (→ mud),
  use the per-cell DOMINANT family colour + purity-fade + desaturate + lift. Cache the field
  offscreen, rebuild on colour/axis/filter change, never on hover. Per-one-shot CLAP
  genre/mood is unreliable (0.3s hits) but CLAP **embeddings are gold for relative
  similarity** (vibe-search / find-similar). Seed free-text search with the user's OWN words.
  DarkReader can fight a dark theme — suspect the inverter before re-tuning to a phantom.
- **Deps:** Spawned #63 (cross-app SPA landing — the tide-table index shipped; tying in the
  React surfaces remains). Set the foundation the emotion-engine work (#89–#92) built on.

---

# The Foundry — phase-2 loops chain (overnight, 2026-06-23)

One session built the entire url→stems→**loops** chain end to end: grader, tierlist,
correlation, finder, naming, GUI, plus engine2 (merge) and audio hardening. Every claim
below is measured; honest negatives kept honest.

## #8 — engine2 Roformer VRAM spike
- **Done:** `research/engine2_spike/spike.sh`; commit 1a201df.
- **Learnings:** Roformer FITS the 6GB RTX 2060 (peak 3051 MiB, ~0.55× realtime) — the
  wall was never VRAM, it was Python: py3.14 breaks (beartype rejects audio-separator's
  `Callable|None` hints); a py3.12 venv works (`~/.virtualenvs/audio-separator`).
- **Deps:** fed #9.

## #10 — Loop grader (the katana), engine/grade.py
- **Done:** pure `grade(wav)->LoopGrade` (S/A/B/C/D + per-rule subscores + flags); commit edb6225.
- **Learnings:** (1) librosa 0.11 tempo is `librosa.feature.tempo`, NOT `.rhythm.tempo` — a
  swallowed AttributeError flat-lined bar-consistency at 0.50 for every file. (2) seam =
  CONTINUITY = the 2nd difference `|x0-2x[-1]+x[-2]|` at the wrap, not the single-sample step
  (the step metric punishes a clean wrap at a zero crossing — a perfect sine scored 0.40).
- **Deps:** reused by #5, #7, #11, #18, #19.

## #11 — Hall of Fame tierlist (data + impeccable viz)
- **Done:** `build_tierlist.py` (1690 loops/97 kits) + `tierlist.html`; commits f88273b, 4c9a831.
- **Learnings:** grade↔.tidal-usage Spearman ρ=+0.34 (rubric points right). The CONFOUND is
  the insight: FX kits (risers 16×, weird_dialogs) tier LOW correctly — sweeps aren't loops,
  so the grader tells loops from transitions; custom `f*` palette validates from the other
  side (fsynth/fguitar A). Only 34/97 kits carry a usage token (folder-name vs `s`-token).
  Showed the confound in the scatter rather than hiding it (trust the instrument).

## #12 — Correlation engine (provenance recovery), engine/correlate.py
- **Done:** NCC sample↔stem matcher + `build_provenance.py`; 482/1690 matched (29%),
  cross-stem alignment 0.34; commit 7a79dc8.
- **Learnings:** a hand-cut loop is a literal slice → NCC peaks ~1.0 (foreign ~0.03). TWO
  load-bearing bugs: (a) naive all-pairs full-res took 56 MIN → fixed with coarse-to-fine
  (rank stems on a frame-RMS ENVELOPE, precise NCC on top-3, ~40× cheaper, minutes); (b)
  SILENT-WINDOW BLOWUP — catastrophic cancellation in the sliding-variance cumsum drove the
  normaliser to ~0 in quiet regions, so silence scored ∞ and faked perfect matches (a run
  reported a bogus 55%/0.69, ~440 false positives). Floor the window norm at 5% of global
  energy + clamp NCC to [-1,1]. Also: py3.14 mp defaults to FORKSERVER → COW-global trick
  fails (empty globals per worker); pin `mp.get_context("fork")`.

## #5 + #18 — Loop finder (engine/loops.py) + v2
- **Done:** §1 pipeline (PLP→beat-sync SSM→Foote→scored windows→Takes→export); grid-from-drums;
  chop mode; commits c17dd0d, de0cb11, cdf2f13, 0a2921a, 5ac5cd0.
- **Learnings:** bare PLP latches the eighth-note pulse → ~2× tempo (Loituma read 250 not 125);
  pin tempo_min/max to one octave + min peak distance. Per-stem PLP grids are WEAK on
  sparse-onset vocals → grid-from-drums (one grid from the rhythmic stem, reused for all)
  lifted recall 0.27→0.34 (vocals 0.27→0.50). Chop mode (onset sub-bar) WORKS but recall
  0.13 < loops 0.34 — PLN picks chops by TASTE from many onsets (recall is a harsh metric;
  onsets over-segment, floored chops at 0.25s). Loops=default, chops=opt-in.

## #7 — Finder recall harness, build_finder_eval.py
- **Done:** recall vs provenance GT (distinct-window, uncapped N + precision); commit 09bace6.
- **Learnings:** honest 0.27 recall / 0.24 precision on the 8 most-cut stems — BELOW the 0.70
  target, and that's diagnostic not failure: those are chop-heavy sources (14-42 sub-bar cuts
  /stem); the bar-finder can't reproduce sub-bar chops, and the finder is a SUGGESTER anyway.

## #9 — Merge-of-stems multimodel, engine/separate.py
- **Done:** MultiModelBackend cascade (Roformer vocals → demucs on the instrumental for
  drums/bass/other → merged 4-stem) + wired RoformerBackend + demucs model selector; commits
  853eeaf, 14e69fe.
- **Learnings:** there is NO 4-stem Roformer (all are 2-stem) — the win is an ENSEMBLE.
  htdemucs_ft is a free drop-in 4-stem A/B (vocals SDR 10.8 vs 9.9). Refactored backends to
  declare `produces` + `run_composite`. Verified on Loituma: 4 clean PCM_16 stems.

## #13/#16/#17 + audio fix — supporting wins
- **#13 naming** (engine/naming.py, 146070b): NN_role[_character] derived from the corpus
  (46% indexed, 74% underscore, kit-name not repeated).
- **#16 live MIDI panel** (c0fd5d4): SSE fan-out into the Bridge, colour-by-event.
- **#17 self-updating corpus essay** (24606d4): templated Story 02 from EDA + freshness badge;
  honesty fix — 4→14 gigs is "grew 3.5×", not the hand-copied "tripled".
- **AUDIO STREAMING fix** (7c3154a): stdlib http.server defaults to HTTP/1.0 → `<audio>`
  seeking (many small Range requests) is flaky in real browsers. Set
  `protocol_version="HTTP/1.1"` on the Foundry Handler AND armada RangeHandler (safe: every
  response sets Content-Length). Also preload="metadata" so durations show (0:00/0:00 read as
  "broken"), and offer the ORIGINAL alongside stems. A fleet-wide gotcha for any audio server.

- **Deps:** the chain feeds #19 (auto-tune settings against the grader) and #20 (batch-explore
  many sources). Engine registry now seeds the product/API/service direction PLN wants.

---

# Fourier — birth of the Audio-Intelligence API (2026-06-25)

Designed live with PLN via a long AskUserQuestion co-design (5 rounds), then built lean. The
product: a self-hosted, token-authed, async API in `armada/api/` exposing the fleet's engines
(emotion, samples, features, separation, loops) to hexa (Shipow) + future seats. Full design in
memory `project_fourier_api`. Named after Joseph Fourier — the transform behind `/spectrum` and the feature stack. [Codename renamed 2026-06-29 from the original "Douanier" (Le Douanier Rousseau).]

## #22 / #36 — Scaffold + emotion walking skeleton
- **Description:** stand up the service shell + ONE real endpoint to de-risk the three seams
  (engines import · service runs · client can auth) before building auth/jobs/cache.
- **Done:** FastAPI app (`app.py`) versioned `/v1`; `/v1/healthz` + synchronous
  `POST /v1/analyze/emotion` (upload → valence/arousal via the tide-table CLAP seam
  sample_semantics.embed_audios → emotion_ontology.score); `engines/` lazy-import adapter;
  runs in `~/.virtualenvs/fourier` (--system-site-packages). Commit 7f558a7.
- **Learnings:** lean rewire — the cheap "ears" don't need the job queue/quota for one trusted
  customer, so a walking skeleton (sync + stub token) reaches value in ~1 session vs 6 tasks.
  Arch PEP-668 blocks system pip → venv that INHERITS the multi-GB ML stack, adds only FastAPI.
  CLAP cold-load is ~34 s (→ later #28 warms it once in the worker). Real pipeline proven on a
  synthetic clip end-to-end.
- **Deps:** root of the whole Fourier tree.

## #23 — Bearer-token auth + scopes + the `fourier` CLI
- **Description:** replace the dev-token stub with a real self-hosted token store.
- **Done:** `db.py` (stdlib sqlite3, WAL for API+worker concurrency); `auth.py` — mint()
  returns plaintext ONCE, stores only sha256 (DB leak unreplayable), verify() → Principal with
  scope/expiry checks, `require_scope()` dependency (401/403); `fourier.py` admin CLI
  (token mint/list/revoke). `/v1/me` echoes identity. Commit fe9ca02.
- **Learnings:** scope='*' wildcard for hexa; dev-token kept as a documented local-only
  bootstrap (prod never sets it) so curl works without minting. 15/15 tests; mint expiry landed
  exactly 31 days out.
- **Deps:** unblocks #24 quota, #33 hexa token; gates every future engine route.

## #26 — Content-addressed cache (the margin lever)
- **Description:** identical audio analyzed once, served forever — the economic core.
- **Done:** `cache.py` — content_id = sha256 of DECODED PCM (re-encodes dedupe) + raw-bytes
  fallback; params_hash folds engine settings; get/put (JSON inline or result_ref path);
  yt-id/url→content_id aliases; LRU/size-cap gc() that unlinks evicted artifacts. Wired into
  `/v1/analyze/emotion` (`X-Fourier-Cache: hit|miss`). Commit 394bec0.
- **Learnings:** **measured 5663× on a real CLAP run** (10.6 s miss → 0.002 s hit). Cache keys
  on the engine so clap/light don't collide. 22/22 tests. This is the margin story vs cloud
  egress that justified self-hosting.
- **Deps:** underpins every engine endpoint + job idempotency; unblocks #27.

## (no task) — Sampling-classics demo sources (Foundry)
- **Done:** `tools/foundry/demos.json` — 10 classics (Amen/Funky Drummer/Apache breaks, Chic
  bass, Loituma vocal, Mozart no-drums, Rickroll, GM Flash, Humpty, Think) + loader + `foundry
  demos` CLI + `/api/demos` + UI quick-picks; `/api/fetch` accepts `ytsearch1:`. Commit fd1fae8.
- **Learnings:** only verified-id URLs ship as links (Loituma from the repo fixture, Rickroll);
  the rest are `ytsearch1:` queries so we never ship a guessed/broken video id. Pedagogically
  diverse on purpose — fuel for #19 autotune + #20 batch-explore.
- **Deps:** feeds #19 / #20.

## #37 — Refit for erable: CPU image + nech.pl platform contract
- **Description:** the SRE replied (SRE.md) that erable has NO GPU / ~2 GB RAM and CLAP/torch
  won't fit; ship a CPU-deployable image. Same letter reframed api.nech.pl as a multi-API
  PLATFORM (we're the `audio` sub-API).
- **Done:** Dockerfile (python:3.12-slim + ffmpeg/libsndfile + torch-free requirements-deploy.txt)
  → **~1 GB image**, <2 GB RAM, default engine=light. Conformed the app: public path `/audio/v1`
  (gateway strips it; routes at root + `root_path`), **central auth** (trust gateway-injected
  X-Tenant/X-Scopes, loopback-only; dropped our SQLite bearer/quota = #23/#24 superseded),
  `/metrics` Prometheus, `/separate` → 503 `compute_unavailable` seam. DEPLOY.md = erable
  contract. Commit 722dade.
- **Learnings:** the SRE EDGE WENT LIVE first (503 warming_up at the public URL) — deploy is
  now a one-command unblock. The container holds ONLY armada/api/ → every engine must be
  self-contained (no tide-table/Foundry import at runtime). 32/32 tests; validated in-container.
- **Deps:** unblocks #42 deploy + #33 onboard; superseded #23/#24.

## #38 — Modular ears building blocks: /features + /analyze/samples
- **Done:** two CPU/torch-free cached endpoints. `/features` (~35-dim spectral/MFCC/chroma/key/
  envelope + rhythm); `/analyze/samples` (per-sample EDA + role percs|bass|melodic|tops|atmos by
  MEASURED spectrum, never the name). `engines/feats.py` self-contained (vendored from
  sample_features/audio_lens). Shared `_cached()` helper (DRY across the 3 analyze routes). Commit 9eb2623.
- **Learnings:** vendoring fixed the librosa-0.11 `feature.rhythm.tempo` bug the tide-table
  original silently swallowed (→ `feature.tempo`). Role-by-measurement upholds feedback_mastering_eda.
  40/40 tests; validated in the torch-free container.
- **Deps:** established the vendoring pattern reused by #39/#41.

## #39 — /grade: the Foundry loop grader as a building block
- **Done:** `/grade` exposes the Foundry mechanical quality grader (composite 0..1 + S/A/B/C/D
  tier + seam/zc/bar/level/dc/bass sub-scores + flags). `engines/grade.py` is a BYTE-FOR-BYTE
  vendored copy; tests/test_grade_endpoint.py is a DRIFT GUARD (imports the Foundry canonical
  standalone, asserts identical grade/tier/sub + WEIGHTS/THRESH). Commit cd3caa7.
- **Learnings:** vendoring + a drift-guard test = DRY-safe deployment when the container can't
  hold the source repo (parsers-over-copy applied to a vendored copy). 43/43 tests.
- **Deps:** the CPU slice of #31 (/loops + /correlate still need a corpus/finder).

## #40 — Low-hanging endpoints: /onsets, /waveform, /analyze
- **Done:** `/onsets` (hit times + tempo + rate), `/waveform` (per-bin [min,max] + 0..1 RMS
  envelope — render-ready), `/analyze` (composite emotion+features+sample in one cached call).
  `engines/signal.py` self-contained librosa. Commit 8c2be60.
- **Learnings:** /onsets+/waveform are aimed at hexa's hydra VISUALS (rhythmic sync + reactive
  envelope) — the API's value isn't just analysis, it's render-ready signal. 50/50 tests.
- **Deps:** —

## #41 — /loudness, /spectrum (FFT-as-a-service), /naming
- **Done:** `/loudness` (BS.1770 integrated LUFS via pyloudnorm + 4×-oversample true-peak +
  crest + gain-to-target -14/-9); `/spectrum` (downsampled mel/linear spectrogram bands×frames,
  0..1, frames=1=avg spectrum — PLN's "FFT-as-a-service" idea); `/naming` (vendored Foundry
  namer, drift-guarded, + feats.character_of). pyloudnorm added to requirements-deploy. Commit f11bef9.
- **Learnings:** /spectrum is the canonical cache-as-margin building block — compute the FFT
  once, serve the bounded matrix forever. **11 engines total.** 58/58 tests.
- **Deps:** —

## #32 — Make it callable: OpenAPI snapshot + zero-dep TS client
- **Done:** `clients/openapi.json` (3.1 snapshot, servers pinned to api.nech.pl/audio/v1, +
  drift-guard test vs the live app), `clients/fourier.ts` (typed, zero-dep, one method/engine,
  `_cache` surfaced, FourierError; tsc --strict clean), `clients/README.md` (Vercel example +
  scope table + codegen one-liner). Commit 11c8f9d.
- **Learnings:** a hand-written zero-dep client beats generated bloat for ~11 simple multipart
  routes; the snapshot-drift test keeps hexa's spec honest as routes evolve. 60/60 tests.
- **Deps:** unblocks #33 (onboard hexa); needs #42 deploy to be live-callable.

## #42 — Deploy fourier:latest to erable → green /audio/v1/healthz
- **Done:** First real deploy of the CPU image to erable (pln@nech.pl). Image transferred via
  `docker save | ssh erable docker load`, run loopback-only on 127.0.0.1:9780. Registered
  /openapi.json + /docs as public gateway routes (nechapi 89586eb). DEPLOY.md gotchas captured
  (Tidal fbe1742). Green end-to-end: healthz/openapi/docs 200, authed 401 without token.
- **Learnings:** TWO erable traps — (1) `--security-opt seccomp=unconfined` is MANDATORY:
  Docker 19.03/kernel 4.9 default seccomp blocks the `clone3` syscall that python:3.12-slim's
  glibc 2.36 uses for pthread_create → container boots uvicorn then SIGSEGVs (exit 139) /
  "OpenBLAS pthread_create … Operation not permitted". (2) pin OPENBLAS/OMP/NUMEXPR/MKL
  _NUM_THREADS=1. The _platform service sidesteps #1 by being Alpine/musl on purpose. Data
  volume = ~/srv/fourier/data (no sudo for /srv; pure transient cache).
- **Deps:** unblocked #33; the keystone for the whole live API.

## #43 — Hierarchical scope convention (realm:domain:path) + central enforcement + CLI/docs
- **Done:** Convention `realm:domain:path` (realm=api|admin|future mail/data); required scope
  DERIVED from the path (api:audio:analyze:emotion), subtree wildcards (*, api:*, api:audio:*),
  non-wildcard=exact, api:* never grants admin:*. Canonical matcher `_platform/scopes.py` vendored
  byte-identical into `armada/api/scopes.py` (drift-guarded). Audio app: per-route scope strings
  replaced by ONE path-derived `require` dependency + footgun fix (missing X-Scopes → "" not "*").
  CENTRAL enforcement deployed in `_auth` (derive + 403). CLI validates scopes + `check` cmd.
  nechapi 104748f, Tidal 4197696. Verified: hexa api:*→200, admin admin:*→403 on audio, no-id→401.
- **Learnings:** PLN chose a top-level REALM (vs flat domain root) to future-proof for non-API
  capabilities (mail, personal data). Path-derived scopes = maintenance-free access control
  (add a route, its scope exists). The realm split is what lets a customer hold api:* (everything
  API) while admin stays a separate sealed plane. Tenant migration caveat: the active `maps`
  token has OLD-style geo:read/route — must become api:geo:* BEFORE geo joins the gateway.
- **Deps:** built on #37 platform reframe; enables clean multi-API + selling seats.

## #33 — Onboard Shipow / hexa (the MVP delivery)
- **Done:** hexa minted as tenant 3, plan pro, scope api:* (full platform access). Verified
  end-to-end through https://api.nech.pl/audio/v1 with his real token: /me, /features (cache
  miss→hit), /analyze/emotion. Branded onboarding PDF (clients/onboarding.html template →
  chromium --print-to-pdf; rendered PDF gitignored as it carries the token). Tidal b225ba7.
- **Learnings:** platform tokens DON'T expire (the schema has no expiry — revoke-by-prefix
  instead), so the old "31-day" idea didn't apply. Token plaintext is shown ONCE at mint (only
  the sha256 is stored). Onboarding artifact is transitional — PLN's stance: Scalar /docs +
  landing will replace the PDF; "only token distribution matters." Client SDK renamed
  Fourier→NechAPI (umbrella + .audio namespace) + header X-Fourier-Cache→X-Nech-Cache so the
  internal codename never reaches a consumer.
- **Deps:** was blocked by #42 (deploy) + #43 (scopes); closes the EPIC's MVP.

## #45 — Platform observability: usage analytics, security monitoring, uptime status landing
- **Done:** (1) CAPTURE — `_auth` logs EVERY attempt to usage_log (status 200/401/403 + reason +
  client ip; tenant_id nullable; quota counts 200 only); migration +reason +ip +health_checks.
  (2) ADMIN ANALYTICS (admin:*-gated, self-authed): /admin/usage (totals, by-tenant, top routes,
  14d series) + /admin/security (rejects by reason, offender IPs, recent rejects). (3) PUBLIC
  STATUS — api.nech.pl/ now renders per-API up/down + uptime% (24h/7d/30d) from a 60s background
  probe → health_checks; ca-certificates added for the https probe. nechapi e489d26. Verified:
  landing renders, admin endpoints return real data, hexa api:*→403 on /admin (plane sealed),
  rejects logged with source IP.
- **Learnings:** before this, _auth returned early on reject and logged NOTHING — a total
  security blind spot; logging the decision (not just authorized calls) is the foundation.
  Probing the PUBLIC healthz URL (not loopback) measures the real edge a caller sees — and a
  container can't reach the host's 127.0.0.1 anyway. nginx reload briefly fails one probe → a
  small transient uptime dip that self-heals. The admin/api realm split makes /admin trivially
  safe (an api:* token is auto-403'd).
- **Deps:** built on #43 (admin realm) + #45 capture; Grafana visual layer scheduled as #46.

## #34 — Ops: systemd units, observability, SRE deploy runbook
- **Description:** the ops half of the audio-API rollout — keep-alive units, observability,
  a deploy runbook for SRE.
- **Done:** closed as SUBSUMED by the platform's paved road (which appeared in a parallel SRE
  session between sessions). Observability shipped earlier (nechapi f89d7f9 — per-call capture,
  /admin analytics, public uptime status page); the systemd-restart + deploy-runbook half is now
  the golden base image (nechapi/py) + `nechapi ship` CLI + the onboarding runbook in the SRE
  repo, not bespoke units here. Reconciliation documented in armada/api/SRE.md (Tidal d228966).
- **Learnings:** the platform repo had DIVERGED — my 3 unpushed control-plane commits
  (observability, scope convention, audio-LIVE) vs origin's 10-ahead paved-road refactor (golden
  base, ship CLI, edge-cache tiers, geo/v1 + iris/v1 onboarded). Resolved by rebasing mine onto
  origin/main: only docs + the registry + the generated vhost conflicted; the core observability
  code (_platform/app.py, scopes.py, schema.sql) was untouched upstream and replayed clean. The
  generated vhost (api.nech.pl.conf) is re-rendered, never hand-merged. PARKED RISK flagged: geo
  is live with old-style scopes (geo:read) but my central enforcement derives api:geo:* — so if
  _platform is redeployed WITH central enforcement, geo tokens 403 until re-minted; enforcement
  stays parked (audio self-enforces) and that reconcile is SRE's.
- **Deps:** part of the EPIC #21 thread; observability lineage = #45.

## #35 — Test suite + CI for the API
- **Description:** a real test suite for the audio API plus a CI pipeline.
- **Done:** the 67-test suite already existed and was green, but only under a
  --system-site-packages venv (quietly host-dependent). Made it hermetic:
  requirements-test.txt pins the true test surface (fastapi/uvicorn/multipart + numpy + soundfile
  + librosa + pyloudnorm + httpx + pytest), NOT system-site-packages; .gitea/workflows/api-ci.yml
  runs it on git.plnech.fr for any push/PR touching armada/api/**. Tidal 213eb8a. Clean-venv
  rehearsal: 67 passed.
- **Learnings:** a system python bump to 3.14 had dropped fastapi/httpx from the old venv → the
  whole suite reported "no tests collected" (red for an ENV reason, not a code reason) — exactly
  what a site-packages-inheriting CI would hide. Rehearsing in a clean throwaway venv surfaced
  TWO deps the system venv had silently supplied: librosa (lazy-imported inside engine functions,
  not at module load — so a grep for eager imports missed it) and pyloudnorm (the LUFS engine).
  The heavy ML stack (torch/laion_clap/demucs) is NOT needed by the tests, so CI installs fast.
  CI is dormant until an act_runner is registered for the repo; the workflow is correct + locally
  rehearsed.
- **Deps:** part of EPIC #21; was a blocker on #21.

## #25 — Async job model + worker daemon (SQLite)
- **Description:** the async backbone so heavy work (sources/separate/loops) runs out-of-band and the API stays responsive.
- **Done:** committed 17d3f46. Four design decisions taken with PLN (AskUserQuestion w/ sketches): HYBRID sync/async (cheap analyses stay sync, heavy = jobs), PER-ENGINE submit (202 {job_id}) + SHARED poll (GET /jobs/{id}) + cancel (DELETE), SINGLE serialized worker, X-Accel-Redirect for artifacts. Built: jobs table (db.py), jobs.py (enqueue + born-done idempotent fast path, atomic claim, progress/finish/fail-requeue-under-cap, crash recovery, cooperative cancel), worker.py (serialized recover→claim→dispatch→webhook loop, SIGTERM-graceful, @jobs.handler registry), typed JobStatus endpoints, deploy/fourier-worker.service (systemd --user). 11 new tests (67→78), incl. a real 6-thread×25-job no-double-claim concurrency test. OpenAPI refreshed (15 ops) + @nech/api regenerated (getJob/cancelJob).
- **Learnings:** SQLite WAL serializes writers, so the claim race reduces to candidate-select → `UPDATE ... WHERE id=? AND status='pending'` + rowcount-0 retry — no locks needed, provably exactly-once across threads. typescript-fetch's generated instanceOf guard indexes a camel/snake key union that trips TS7053 under noImplicitAny → relaxed noImplicitAny for the 100%-generated package (emitted .d.ts stays fully typed). MID-BUILD: the btrfs disk hit ENOSPC at 98% (20G "free" but unallocated, metadata-full) — writes failed intermittently; cleared ~5.7G of regenerable caches (npm + ~/.cache browser/HF, user-approved) to continue. Disk still 97% — flagged for real cleanup.
- **Deps:** was blocked by scaffold + central auth (done); unblocks #29 → #30 → #31. Worker prod container-vs-host wiring is a paved-road residual (SRE/#34).

## #27 — Artifact store (local disk on erable; freebox deferred)
- **Description:** somewhere to keep the big rebuildable binaries (fetched sources, later stems/loops) that don't belong inline in SQLite, served efficiently to hexa.
- **Done:** committed c7e5392. `artifacts.py`: content-addressed layout `ARTIFACTS_ROOT/<cid>/<name>`, tracked as ordinary `cache` rows (kind→result_ref→path, bytes) so the **existing LRU `cache.gc()` evicts them for free** under `FOURIER_ARTIFACTS_MAX_MB` (1 GB). `resolve()` refuses path traversal / escapes outside the root. Serving = zero-copy nginx `X-Accel-Redirect` (`FOURIER_X_ACCEL`), `FileResponse` fallback in dev. `GET /artifacts/{cid}/{name}` endpoint (auth-gated, any tenant holding the opaque ref). DEPLOY.md §5b documents the internal nginx location. The cache schema's `result_ref` column + `gc()` unlink were pre-built in #25/#26 *for* this — #27 was mostly wiring.
- **Learnings:** PLN dropped freebox-as-SSOT ("forget freebox storage for now") → the store collapsed from "signed Range URLs over a remote mount" to "local dir + nginx internal location", much simpler. Reusing the cache table for artifacts means one eviction policy, one stats surface, one GC — no separate artifact lifecycle. The `_meter` middleware had to switch from `request.url.path` to the **route template** (`request.scope["route"].path_format`) or `/artifacts/{cid}` + `/jobs/{id}` would each mint an unbounded Prometheus series.
- **Deps:** was blocked-by-nothing once #25 landed; unblocks #30 (stems need somewhere to live). Go-live (nginx location + FOURIER_X_ACCEL) tracked in #47.

## #29 — /sources endpoint (yt-dlp fetch as a job)
- **Description:** turn a URL into analysis-ready audio — the front door of the heavy chain (sources→separate→loops).
- **Done:** committed c7e5392. `engines/sources.py` registers the `sources` job handler: yt-dlp → `bestaudio` → 44.1k WAV (PLN's choice: analysis-ready PCM over keep-source-codec), content-address, store as a `source` artifact, `put_alias(url→cid)`. `POST /sources` returns `202 {job_id}`; a re-fetched URL (alias hit + artifact still on disk) is a **born-`done` job** — no re-download. `normalize_url()` validates: http(s) only, lowercases host, drops fragment (so trivially-different URLs dedupe), and hard-blocks SSRF footguns (localhost, RFC1918/loopback/link-local/reserved IP literals, incl. the 169.254.169.254 cloud-metadata classic). The fetch is a thin seam (`_fetch_audio_wav`) so tests mock the network with a synthetic WAV — 17 new tests (78→95), zero network. healthz reports yt-dlp presence honestly.
- **Learnings:** PLN chose OPEN url scope (any http(s)) over an allowlist, explicitly leaning on "our apikey + nechapi monitoring" as the protection layer — so the code accepts any public URL but still refuses the SSRF set in-process (defense the gateway can't do). yt-dlp+ffmpeg are **worker-only** deps (the API container stays torch/yt-dlp-free) → added to requirements-deploy, not requirements-test. The idempotent born-done path falls out of the #25 `enqueue(..., result=...)` fast path + #26 aliases — the URL→cid alias table was designed in #26 *for* exactly this.
- **Deps:** blocked-by #25 (done); unblocks #30 (separate consumes the fetched WAV). Go-live (yt-dlp install on erable) tracked in #47.

## #48 — Gate /docs + /openapi.json behind a cross-cutting api:docs scope
- **Description:** PLN noticed `api.nech.pl/docs` "down" + asked if docs should be token-protected. Investigation found two things: (1) root `/docs` is 404 (the platform Scalar portal was never built; the 404 catch-all's "See /docs" hint dangles), and (2) the real issue — audio's `public_routes` exposed `/audio/v1/{docs,openapi.json}` ANONYMOUSLY: the full API surface advertised to the internet.
- **Done:** deployed live 2026-06-29. `required_for()` special-cases `docs|openapi.json|redoc` → `{realm}:docs` (canonical `nechapi/_platform/scopes.py` commit on branch `claude/docs-scope`, rebased onto upstream + merged to main `0ab148b`; vendored byte-identical into `armada/api/scopes.py`, commit `d4d27a4`). Dropped docs/openapi from `audio.public_routes` (kept `/healthz`), re-rendered the vhost. Rebuilt + shipped `nechapi-platform:latest` (Alpine, Postgres-backed so no token loss), recreated the container on `nechapi-net`, installed the re-rendered vhost (diffed-first: only my 2 blocks removed), `nginx -t && reload`. VERIFIED LIVE: anon /docs+/openapi.json → 401 (was 200), /healthz → 200; a minted `api:docs` token → /docs 200 but POST /analyze/emotion → 403 (decoupling proven); test token revoked. `api:*` (hexa/pln) keeps docs access via subtree.
- **Learnings:** the cross-cutting scope (`api:docs`, not per-API `api:audio:docs`) decouples "read the specs" from "call the API" — a docs-reader token can't invoke anything, and `api:*` still covers both via the subtree wildcard. The drift guard reads the MAIN `~/Work/nechapi` checkout, so the canonical + vendored copies must merge in LOCKSTEP — committing the vendored copy ahead of the canonical merge turns the local suite red (CI skips it when the canonical is absent). Resolved by reverting the premature vendor, staging on a branch, and re-vendoring only after the nechapi merge. Also re-learned the shared-repo race: origin/main had advanced (a parallel SRE `bin/nechapi` drift-guard commit) — rebased onto it (no overlap), and ALWAYS diff the rendered vhost vs the LIVE one before `sudo tee` so a parallel session's work isn't clobbered.
- **Deps:** spun out of the #29/#27 deploy session; touched the security control plane (gated). Follow-up: build the platform `/docs` Scalar portal (basic-auth RTFM/PLN per registry) + fix the 404 "See /docs" dangling hint.

## #44 — Official generated clients + private npm (Verdaccio)
- **Description:** ship a real, never-drifts client for hexa + future seats, distributed from a private registry — replacing the interim hand-written `nech.ts` drop-in.
- **Done:** deployed live 2026-06-29. (a) Generated `@nech/api` TS client from the OpenAPI snapshot (openapi-generator typescript-fetch → tsup ESM bundle; clean operationIds → `audio.analyzeEmotion()`; subpath exports). (b) Stood up **Verdaccio 6** on erable (loopback `:4873`, `~/srv/verdaccio/{storage,conf}`, `unless-stopped`) behind an LE-TLS nginx vhost **`npm.nech.pl`**; seeded publisher `pln` via the user API, locked signups (`max_users:-1`), creds 0600 at `~/.config/nechapi/verdaccio.pln.{pw,token}`. (c) Published `@nech/api@0.1.0` and install-verified as a consumer (AudioApi/Configuration import clean). (d) Retired `nech.ts` (`git rm`), updated README + onboarding.html to the `@nech/api` install path. Commits: client `ed77ee9`/regens, retire `851767f`; VERDACCIO.md marked DEPLOYED.
- **Learnings:** three Verdaccio gotchas the bare runbook missed — `listen: 0.0.0.0:4873` (else binds localhost INSIDE the container, dead `-p` map), `chown -R 10001:65533` the bind-mount (verdaccio uid), and `ssl_protocols TLSv1.2;` ONLY (erable's old OpenSSL rejects TLSv1.3 and fails `nginx -t`). Non-interactive publish: create the user via `PUT /-/user/org.couchdb.user:<name>` to get a token, then `npm publish --userconfig <temp .npmrc with _authToken>` — no interactive `npm adduser`/`login` needed. KEY DECISION: `@nech/*` reads are `$authenticated` (not public) — consistent with the bearer-gated `/openapi.json` (#48); so consumers need an npm READ token, a deliberate consistency call over install-convenience.
- **Deps:** unblocked by the OpenAPI snapshot (#32); part of EPIC #21. Follow-ups (in `armada/api/TODO.md`): a `nech_api` Python client; mint hexa a read token.

## #63 — The silent-orbit epidemic: `doNotReadYet` loads WAV headers only
- **Description:** for two evenings, orbits in `the_revolution_will_be_sampled` (d4/d5/d9-d12, varying per boot) made no sound. Events were visible in the Pulsar highlighter, no error appeared anywhere, and every static check — patterns, g* helpers, sample banks, libsndfile readability, orbit count, PipeWire links, Ardour routing, saved faders — pronounced the rig healthy while it was demonstrably not.
- **Done:** root cause identified 2026-07-28. SuperDirt `DirtSoundLibrary.sc:221` reads `Buffer.readWithInfo(server, path, onlyHeader: doNotReadYet)`. With `~dirt.doNotReadYet = true` (start_and_midi.scd:202) it reads **only the WAV header**: the buffer is allocated with a correct frame count and duration but contains NO AUDIO DATA. That is verbatim the `Buffer UGen: no buffer data` message. The deferred real read is what decides whether an orbit sounds, so which orbits die changes per boot, and a re-eval sometimes revives one by retriggering the read. Fix (eager-load the set's banks) spun out to #26 and deliberately NOT shipped at J-7 without index-integrity validation. Tooling shipped alongside: `872676a` (probe-chain contamination guard), `a43f428`+`d3de9bd` (remote control channel + CLI), `66af326` (eager package activation, which also closed #59).
- **Learnings:** THE METHODOLOGICAL ONE, worth more than the bug: every measurement used a 4-8 second window, and at that timescale a TIME-VARYING failure looks STATIC. "These two sample banks are dead" was overwhelmingly convincing and completely wrong; six hypotheses were built and demolished on top of it (banks, symlinks, 24-bit depth, filename characters, buffer exhaustion, xfade). The decisive observation took one command: play `arpy`, measure at t+3s (-37.0 dBFS rms, healthy) and again at t+26s (-inf, gone), no re-eval between. Patterns play, then die. Sampling ONE subject at TWO times would have found this in five minutes. Second lesson, more uncomfortable: probe-chain FABRICATED signal — contaminated capture taps reported five orbits at an identical +0.0 dBFS peak (twelve independent orbits don't do that; it was the mix measured five times), and that false table said d4/d5 had recovered while PLN's ears and Ardour's meters said otherwise. A measurement tool that invents signal outranks the human in an argument it should always lose; it now refuses to report unverified rows. Also: `find` does NOT follow symlinked directories without `-L`, which produced a confident "these files don't exist" about 69 files that do; and a bank name doesn't reveal its loader (`Dirt-Samples/kick` doesn't exist as a folder, yet `s "kick"` plays, because banks also come from `Samples/extra/*` and tidal-drum-machines).
- **Deps:** absorbed the `no buffer data` half of #45 (whose remaining 69 `zz._` AppleDouble warnings are harmless junk); handed the fix to #26, now the gig blocker; unblocked #59 as a side effect.

## #59 — "Sound broke after a Window Reload, fixed by a Tidal reboot"
- **Description:** a Pulsar `Window: Reload` reliably killed audio, and rebooting Tidal reliably restored it. Filed as a mysterious interaction between the reload and the audio stack.
- **Done:** fixed 2026-07-28 (`66af326`, pulsar-tidalcycles). Nothing was breaking. `activationHooks: ["source.tidalcycles:root-scope"]` plus `activationCommands` made Pulsar DEFER loading the package, so a reload landed in a window where the Tidal package was simply DORMANT — no ghci, none of its three UDP ports bound, and nothing saying so. `tidalcycles:reboot` happened to be the first *activationCommand* in the list, so "rebooting Tidal" worked by WAKING the package, not by fixing anything. Removed both keys; the package is now eager.
- **Learnings:** the remedy worked for a reason unrelated to the story we told about it, which is exactly why the cause stayed hidden for so long — a reliable workaround stops anyone asking why. Proof was structural rather than inferred: the renderer's `~/.pulsar/blob-store` contained no compiled tidalcycles code at all, so `lib/tidalcycles` had never been required (a good general check for "did this package actually load?"). Design consequence worth keeping: lazy activation and remote control are mutually exclusive on principle — a channel whose purpose is removing the manual keypress cannot itself require a keypress to come into existence. And shipping package JS always costs exactly one reload, because new code cannot be injected into a running Electron renderer.
- **Deps:** found while debugging why the new remote channel (#44/a43f428) wasn't listening; unblocks unattended launch work (#44).

---

## #26 — GIG-CRITICAL: silent orbits (root cause was OUR preload, not SuperDirt)

**Description.** For two evenings, orbits went silent mid-set. *Which* ones changed every
boot. The boot log looked perfect — every bank registered with the right file count — and
19,741 `Buffer UGen: no buffer data` messages piled up in one 6h session where no
performer would ever see them. Six orbits of `the_revolution_will_be_sampled` were dead on
stage. J-7 to OPAL 2026.

**Done.** `b4f6275` (branch `claude/preload-eager-banks`), in `tools/setlist_samples.py`'s
emitter, plus `setlist_opal2026.txt` and `gig-up.sh`. Also `0ae4116` (probe timeline +
check-mix session resolution) and, in the fork, `374d8a3` (stop swallowing GHC errors).

**Learnings.**

*It was a two-part interaction, and part two was ours.* Upstream, `doNotReadYet = true`
registers banks from WAV **headers only**; the audio read is fired *asynchronously* by
`readFileIfNecessary` (`DirtSoundLibrary.sc:268-274`), which returns immediately — so the
synth for the very event that triggered it gets a bufnum with no samples. That alone is a
first-hit glitch. What made it fatal: our generated `preload.scd` set
`doNotReadYet = false` and **never set it back**, and that flag gates the *only* caller of
`readFileIfNecessary` (`:259-261`). Flag false ⇒ a header-only buffer is never read. Not
late — **never**. So any bank the whitelist missed was permanently silent.

*And the whitelist missed banks by design.* `gig-up.sh` built it from the N
most-recently-**edited** tracks. `the_revolution_will_be_sampled` hadn't been touched
recently, so `like_sugar` and `the_revolution` were never warmed — exactly the banks
feeding the six dead orbits. Whitelist-by-mtime is also why *which* orbits died changed
every boot. The thing that made this feel haunted was file timestamps.

*The load-bearing line is one line.* Restoring `doNotReadYet = true` after warming demotes
the whitelist from load-bearing to a mere optimisation: a miss becomes a slow first hit
instead of a dead orbit. That is the only thing it is safe to be on stage.

*A reload REPLACES a bank, it does not append.* `addBuffer` defaults
`appendToExisting = false` and frees the old bank on the first file (`:42-46`), so a plain
re-load is index-stable — `like_sugar:21` keeps its meaning. An earlier session's worry
about *doubled* indices was wrong, and that wrong belief had made the fix look far more
delicate than it was. Measured: **0 of 1428** bank names even collide across the three
loaders.

*The count assertion caught a bug in itself on its first run,* which is the best possible
outcome for a guard: `jbk_kick: expected 508, got 254`. Exactly 2×, and the *tool* was
wrong. That folder symlinks into the rhadamanthe pack, which carries an AppleDouble
`._X.wav` beside every real `X.wav`; `glob('*.wav')` matches both, SuperDirt's
`pathMatch("*")` skips dotfiles, and `ls` hides them so the folder looks clean.

*Red herring worth remembering:* SuperDirt's "Required 0 MB of memory" is not evidence the
eager read failed — `memoryFootprint` counts frames from the header, so header-only
buffers already report full size. Only audio measurement can prove a preload worked.

**Validation (instrumental, no ears — PLN was at work).** Before: d4/d5/d9/d10/d11/d12 all
`-inf` at source. After: all 11 declared orbits carry signal *and still do* on a second
probe ~95 s later with no re-eval (stable to ~0.2 dB) — the two-timepoint check being
mandatory for this bug class. `no buffer data` 19,741-in-6h → **1** (a boot artifact at the
instant the preload completed). Preload asserts **47/47** banks, 2352 files, 2.2 s, 1.2 GB.
Also green: `perfect` 12/12, `gimme_acid` 10/10, `mafia_sans_serif` 7/7, `wap` 5/5. Bare
`s "like_sugar*4"` audible. Confirmed by PLN's ears later that day: *"i hear indeed all
sounds in the track, so it sounds good to me!"*

*Two harness failures during validation, both worth more than the fix.* A batch loop
returned four EMPTY results that read exactly like "nothing is playing" — cause: **zsh does
not word-split unquoted variables**, so `-d $ORB` passed one bogus argument and the tool
exited 2 with a usage error my grep hid. And the readiness gate passes ~4 s too early,
because `listening to Tidal` is printed by `~dirt.start` well before the warm-up. *When a
batch of measurements all come back empty, suspect the harness before the rig.*

**Deps.** Closed #26 and #63. Unmasked #61 (which then measured *clean* for the OPAL set —
all 41 seeded CCs cover it). Spawned #65 (an undefined `crackles` silencing nine orbits,
which is a compile error, not audio), #66, #67. Feeds #44 (readiness gate) and #24.

---

## #66 — Prove all 10 OPAL tracks playable + every control audibly effective

**Description.** PLN left the laptop running at J-7 and authorised autonomous work:
*"ensure all tracks from gig are playable. you can send fake midi events for each, thus
test all controls have impact on sound ;) even the crushes should change noticeably what
you see/'hear'"*. No ears in the room — every claim instrumental, every failure reported.

**Done.** `check-tracks.sh` end-to-end: **10/10 tracks, 91 orbits, zero silent, zero eaten
by an Ardour fader.** Two were broken at the start of the day — `desire.tidal` at **0/9**
(`off 0.125 (|+ note 12 . (|* gain 0.6))`: `.` binds tighter than the section, so it
composed a function into a ValueMap; one pair of parens → 9/9, `cfa56b6`) and
`vague_de_crime` (PLN's own `crackles` fix, saved). Every control measured effective with
the lens that suits it: `gF1` centroid 112→3681 Hz, `gF3` 384→2765 Hz (+529%), `gMute2`
−23 dB→silence, `crushbus` 125→189 Hz (+37%), `ply ^89` 8.1→4.7 onsets/s, `gMask` −33%
onsets, `gMute1`+`gM1` silencing four orbits at once. `ply ^91` on d7 reported
**INCONCLUSIVE** and left that way. Shipped `tools/control-lens.py`,
`tools/check-tracks.sh`, `tools/tempo-lens.py`; commits `cfa56b6` `708c2ae` `7b2e5b4`
`16429b0` `36466a4` `4836d74` `104fb03` `156444d`.

**Learnings — the instruments were wrong more often than the rig, six times, and the
arithmetic was never the bug.**
1. *rms is blind to a filter or a crusher* — they move timbre at constant loudness.
   `gMask` read "NO CHANGE, 0.5 dB": right sum, wrong question; on the density lens, −42%.
2. *`ply` reported "+inf %, EFFECTIVE" off a division by zero* — the envelope onset
   detector read 0.0/s on a sustained bass. The tool invented an effect, the one thing it
   promises never to do. Fixed with spectral flux + an INCONCLUSIVE floor.
3. *A decay crosses its own midpoint exactly once.* A healthy `mask "<t f!7>"` riser was
   called "DECAYING, almost certainly an xfade tail" twice. Two quartile averages cannot
   answer a monotonicity question; counting crossings can.
4. *One low FINAL bin is the window closing, not a fade* — `perfect`'s d4, steady for
   twelve bins, condemned on the thirteenth.
5. *A uniform +1.8% tempo bias* came from a threshold rule sliding down the left flank of
   the RIGHT peak — comparing points on one hill as if they were different hills. (I first
   blamed `np.correlate`'s unnormalised overlap and fixed that too; the bias didn't budge.
   **Fixing a real-but-irrelevant flaw is not evidence you found the cause.**)
6. *The observer was the effect.* Ardour's xrun counter climbed +816 in 25 s under the ply
   stress test — which reads exactly like "heavy patterns cause dropouts on gig week" and
   matched PLN's own crackle report. Remove the ~18 PipeWire capture nodes probe-chain
   creates, repeat the identical musical load: **zero xruns**, scsynth ERR = 1 all session.

*And the discipline that makes the rest survivable:* **A-B-A**. These patterns vary per
cycle by themselves (`d2` of `vague_de_crime` plays a different bar every cycle), so
re-measure the baseline at the END and treat any swing under 2× the self-drift as
inconclusive. Plus: take the baseline **twice** — a single closing repeat agreed with the
opening one by luck and reported drift 0.0% when the true within-condition spread was 33%.

*Also:* `you_my_sunshine`'s alarming "144 vs 166" tempo mismatch was **a commented-out
`setcps` line** — a parser miss masquerading as a data conflict, again. And the RUNBOOK's
pre-set checklist had been telling PLN to put the DJ filters DOWN for bypass; **centre is
bypass**, down is a hard low-pass.

**Deps.** Closed #65. Fed #62 (the live mixer is clean; the SAVED file still isn't),
#56 (event density exonerated as a crackle cause), #12 (one of three tempo questions
dissolved), #33. Spawned #68 (orphan orbits). Ran alongside #67 (LED painter) in a
parallel agent.

---

## #58 — Verify the DJF LED ramp live after reload

**Description.** The LCXL LED feedback had been built, wired into `tidal-remote.py boot`
and `gig-up.sh`, and shipped — but never once seen to light a physical LED. Every
validation to that point ran against a *fake* `aseqdump` stream. PLN's repeated reports
("still seeing static colors", "no touch-reactiveness actually neither on button nor
knobs") could not be distinguished from "the tool is completely broken" because nobody
on either side of the keyboard could observe the device.

**Done.** Closed 2026-07-28 by PLN on real hardware: *"i see hardware led btw, and press
states work! ... i see also filters and effects working as expected"*. The value→colour
ramp, the button press states and the effect colours are all confirmed live.

Supporting work landed the same night (`80b732b`): `tools/mock-lcxl.py`, a virtual
LaunchControl XL that decodes the same SysEx the device receives and renders it in the
terminal, so the convention can be designed, reviewed and regression-tested without
hardware — plus `value_ramp()`, PLN's six-step convention, and 8 regression tests.

**Learnings.**

- *The observability gap was the bug's hiding place.* For three days the LED thread was
  stuck not because the code was wrong but because neither party could see the output.
  Building the mock took under an hour and immediately explained the complaint that had
  survived two rounds of fixes.
- *"Correct" and "looks broken" can be the same picture.* The mock showed knobC 1-3
  painting **bright amber (63)** at rest — which is exactly `filter_colour(64)`, the DJ
  filters at centre/bypass, behaving precisely as designed. Bright amber at rest is
  visually indistinguishable from the factory yellow PLN was trying to escape. The code
  passed every test and still produced the exact experience it was meant to eliminate.
- *A second finding from the same run:* `control_colour()` returns `hue["full"]` for every
  value from 8 to 119, so a touched knob is one flat bright colour across its whole
  travel. Touch changes it once and then it never moves again — the literal definition of
  the "static colors" report.
- *The palette is the design constraint.* The LCXL is bicolor: 2 bits red × 2 bits green =
  16 states, of which roughly six read reliably on a dim stage. PLN independently asked
  for a six-step ramp. The hardware budget and his taste landed on the same number, which
  is a good sign that the convention is the right shape.
- *Transport, re-learned:* `amidi` works on a cold rig and fails on a live one, because
  SuperCollider's ALSA sequencer holds the rawmidi substream. `aseqsend` to the
  **sequencer** port coexists. The tool that works only when nothing else is running is
  the tool that fails at the only moment that matters.

**Deps.** Unblocked the remaining LED thread: #11 (colour convention — the two design
findings above are now its open questions) and #71 (latency: the LEDs work, but lag 1-2 s
behind fast fader moves, because every changed LED costs a `subprocess` fork+exec inside
the reader loop).

---

## #71 — LCXL LEDs lag 1-2s behind fast fader moves

**Description.** First night the LED feedback ran on real hardware, PLN's verdict was
"its great tbh i see also filters and effects working as expected :) but! its slow to
track if i move all fders quickly i see the animations lagging a sec or two behind".

**Done.** `128de62`. A `Painter` thread now owns the wire; the aseqdump reader only touches
the model. Dedupe on COLOUR, coalesce per frame, batch all dirty indices into one SysEx,
and cache port resolution for 2 s (dropped on any send failure). Added `--bench`, and
`tools/at/tests/test_lcxl_latency.py` (12 tests).

**Learnings.**
- *The bug was not what it looked like.* It looked like a slow device or a slow transport.
  It was that we deduped on the CC **value** while colour is a **step function** of value —
  six steps in `value_ramp`, seven in `filter_colour`. A 128-event fader sweep can change
  the board at most 6 times; we were asking the wire for ~20x the work that could possibly
  be visible. Fixing the *representation* mattered more than fixing the I/O.
- *Three forks per LED.* Every `send()` re-resolved the port with `aconnect -o` AND
  `amidi -l` before writing — ~1.7 ms each, measured. The uncached lookup was a deliberate
  earlier fix (a replug had moved the device 20:0 → 24:0), so the cache had to keep that
  property: cache for 2 s, and invalidate **on evidence** (a failed write) rather than on a
  timer. Real cost fell 7.70 ms → 2.72 ms per write.
- *A pipe queues, it does not drop.* All the I/O ran inside `for line in proc.stdout`, so a
  write in flight blocked reading the next MIDI event. Nothing was lost — everything simply
  arrived later, without bound. That unboundedness is why PLN perceived "a second or two"
  rather than a fixed delay. Latency that grows is a different diagnosis from latency that
  is constant.
- *Numbers, replaying the actual complaint.* 8 knobs + 8 faders swept together at 500
  events/s, transport modelled at its measured cost: legacy 744 wire msgs, 3 s of input
  taking 11.23 s (**+8.23 s overrun**); coalesced 48 msgs, +0.00 s, p50 7.9 ms / p99
  20.2 ms. Overrun *is* the visible lag — how far behind his hands the board finishes.
- *Rate-limit as a floor, not a tick.* Limiting the GAP between writes (rather than
  painting on a fixed clock) keeps an isolated button press instant while still bounding
  the burst rate. A fixed tick would have added latency to the common case.
- *The test that matters is the correctness one.* Fast-and-wrong beats nothing, so the
  suite decodes every SysEx the coalescer emitted through `mock-lcxl` and asserts the board
  that LANDS equals a full `build_frame` recomputation.

**Deps.** Followed #58 (which only became possible once LEDs were verified on hardware at
all). Surfaced #72. Its `Painter` became the template for the Bridge fix.

---

## #72 — Bridge MIDI stream dropped the NEWEST event

**Description.** Not reported by anyone — found by sweeping the rest of the gear for #71's
bug class, on the theory that a bug shaped like that rarely lives alone.

**Done.** `1c9df10`. Drop-OLDEST on a full subscriber queue; new `midistream.coalesce()`;
SSE batched into one write+flush per frame. Extracted `_fanout` for testability. 8 new
tests, 32 green in `tools/bridge`.

**Learnings.**
- *`except queue.Full: pass` is a silent lie.* It keeps 512 stale events and discards the
  one that just happened. For MIDI **state** the newest event IS the truth, so a dashboard
  tab that stalled for ~1.3 s at 400 CC/s would show frozen values for the rest of the set
  with nothing logged. Exactly #71's signature: nothing breaks, it just quietly stops
  telling you the truth.
- *State vs events is the whole design.* Continuous controls (CC, pitchbend, aftertouch)
  may be superseded by a newer value; notes may never be dropped. A fast monitor that loses
  a note is strictly worse than a slow one — so the tests assert every note survives a
  flood of 100 controller messages.
- *Preserve chronology.* A superseded value is overwritten **where it stood**, not moved to
  the end, so the monitor still reads as a timeline.
- *Wire format untouched.* Batching emits one `data:` line per event as before, so the
  browser needed no change — a perf fix with a zero-diff client.
- *Green tests on `parse_line` proved nothing about the queue behind it.* Had to extract a
  seam to test the drop policy at all. Same lesson as `feedback_verify_the_plumbing`.

**Deps.** Discovered by #71's sweep. Feeds #25/#42 (the Bridge as cockpit).

---

## #53 — Make master gain fixed pre-set, not live-MIDI (CC77)

**Description.** `midiGGlobal = orDef 0.769 "^77" * 1.3` in BootTidal.hs put a global gain
on one physical fader.

**Done.** `9647e44`. Now `midiGGlobal = 1.0`. Verified with `tools/check-boot.sh`: helper
block typechecks against tidal-1.9.5 under `ghc -fno-code`, the #55 seed block typechecks,
and all 13 g* helpers (incl. `midiGdef`, the one this edit could plausibly break) still
yield events against an EMPTY control map.

**Learnings.**
- *Two independent reasons, and the second was the payoff.* Safety: brushing fader 1 in the
  dark attenuated every midiG-using stream at once, silently. But the unlock was
  ergonomic — Tidal reading `^77` is what kept the whole surface one lane off. Killing it
  freed fader 1 and made fader N → dN possible (#46). A safety fix that also buys a feature
  is worth looking for.
- *Make the refactor provably inaudible.* The old untouched default evaluated to
  `0.769 * 1.3 = 0.9997`, so 1.0 moves every track by 0.003 dB. Choosing the constant to
  match the old default meant no track needed re-levelling.
- *Do not delete a definition the corpus calls.* PLN believed he no longer used `midiG`, and
  behaviourally he was right — with the global term gone it reduces to plain `gain (range
  lo hi cc)`. But `midiG'` appears in 167 files and `midiG` in 12, and deleting a referenced
  definition is a Haskell compile error, which takes the whole `let` block down and silences
  the entire track. Retiring the *name* is #73, post-gig, and the ORDER matters: rewrite the
  call sites first, delete last. Reversed, it is a 179-track mute-bomb.

**Deps.** Unblocked #46. Spawned #73.

---

## #75 — gig-log: a session recorder, so a run-through can be REVIEWED not just felt

**Description.** After a set, "did it glitch?", "did it get hot?" and "which controls did I
actually use?" were answered from memory. PLN asked for logging in place before his morning
run-through, which he records in Ardour: *"we def gotta ensure tomorrow morning when i start
you have logging in place so we can then review the perf and validate."*

**Done** (`4c57362`). `tools/gig-log.py record | mark | report | status | install | selftest`,
running as an enabled `gig-log.service`. 1 Hz JSONL: temps, fan, freq, core+package throttle
deltas, per-core cpu%, per-gear %cpu + RSS (scsynth/sclang/ArdourGUI/whole pulsar group),
PipeWire xruns per node, and every LCXL move. Wall-clock stamped so it aligns with the Ardour
take. `--report` renders sparklines, an xrun timeline, a gear table and a per-CC surface table
with first/last touch. 71 tests.

**Learnings.**
- **Measured, not asserted: 0.40% of one core** (270 pw-top lines parsed in 10 s), 0.79% as
  the live unit. My first selftest said 0.66% and was *flattering itself* — it never started
  the reader threads, so it measured a sampler with nothing to parse. Same mistake as the
  first `--bench` run in the LED work. Re-measured lower AND honest.
- **The logger must not be a firehose.** CC/pitchbend coalesced to one line per control per
  second with count + first/last/min/max — the #71/#72 fix applied to ourselves. Notes are
  never coalesced: a CC is a state, a note is an event.
- **Three wrong xrun baselines before the right one**, each caught by reading the report:
  first-sight → 72096 xruns on a 25 s *idle* session (pw-top prints a zero-filled table
  before the profiler has data); second-sight → silently wrong when TWO zero tables landed;
  finally **max over each node's first 5 sightings**, which needs no timing assumption
  because ERR is monotonic. A rule that is right *most* of the time is the worst kind for a
  gig log, because the one bad reading looks exactly like a disaster.
- **Ardour accumulates ~6 xruns/min with nothing playing** (67072 → 67123 → 67134 across
  captures minutes apart). Only the session delta may ever be quoted.
- Verified in production: **exactly 1.000 Hz, p99 gap 1.001 s, no drift** over 5654 samples.
- Fixed in the shared reader: `perf._read(None)` raised TypeError instead of returning the
  default, so a machine with no coretemp/dell_smm hwmon **crashed** the thermal read — the
  Bridge shares that path.
- `/proc/<pid>/stat` must be split after the LAST `)`: pulsar renderer comms look like
  `(pulsar) --type=renderer`, so splitting from the left silently reports the wrong column.

**Deps.** Unblocks the measurement half of #8 (thermals under load), #56 (Pulsar starving
audio) and #14 (rehearsal review). Feeds #44.

---

## #11 — LCXL colour, settled: red lows / bright-amber centre / green highs, and the ramp finally wired

**Description.** Two taste calls PLN settled by eye on the hardware: sunset the dim-amber
steps on the DJ filters (*"just have red in lows green in highs for clarity"*, *"i agree on
clarity > resolution"*), and make the knobs show more than on/off.

**Done** (`0b23f3d`). `filter_colour` reduced from seven steps to three palette corners —
red / bright amber at the 61-67 detent / green. `value_ramp` wired into `control_colour` for
knobs. Module header rewritten to describe the convention that now exists. +24 tests.

**Learnings.**
- *"wait 'brightness value 6 steps ramp'? I See only two states, dim and not dim"* — he was
  right. `value_ramp` was written, unit-tested, documented in the header, and **never
  called**; `control_colour` had three outcomes, so 30% and 90% were the same colour. The
  old knob rule had **no test at all**, which is exactly how a never-called ramp shipped.
- The old filter ramp spent four of seven steps on shades either side of centre, so the row
  read as a wash of oranges and the one thing you need — *which way is it cutting* — was the
  hardest bit to see. Three unambiguous states beat seven nobody can resolve on a dark stage.
- **The trade, stated so it cannot regress silently:** the ramp spends all three hues on
  VALUE, so hue no longer carries ROLE on rows A/B/C. Right way round — role is fixed by
  POSITION, a knob's setting has no other channel. Keeping "untouched" as a colour collided
  (dim green would mean both "rhythm, untouched" and "value ~3/4"); it moves to the HUD.

**Deps.** Closes the #11 taste call. #49/#51 (colour-by-function, legend) still open.

---

## #76 — panic red must track ^93, not chord membership

**Description.** PLN: *"why are [mutes] 1/2/3 resp red red green? these 3 buttons have same
roles, why diff colours?"*

**Done** (`0b23f3d`). `panic` threaded in from the model (`values[93]`); chord members paint
by role when clear; arming flashes red on all four as a global overlay applied last.

**Learnings.**
- **The panic chord is an OVERLOAD, not a dedicated function.** Hold 73+74+91+92 → SC
  edge-detects and flips persistent `^93`. Those buttons individually are gMute1/gMute2. The
  panic identity **does not exist at rest**, so painting it at rest is simply wrong.
- **The bug was in TWO places**, and the second was only found by *rendering the board*
  after fixing the first: `control_colour` tested chord membership before role, AND
  `parse_track` force-bound all four members to role "fx" via setdefault. A chord is not a
  binding. Verify by rendering, not by reasoning.
- `^93` owns no LED (CC93 is outside row F's 89-92), so those four buttons are the only
  place the armed state can live — and it is the highest-value LED in the rig, because it
  answers *"why is there no sound?"*. Hence overlay-over-dark, not overlay-over-role-only.
- The watch loop rebuilds the whole frame on a `^93` flip: one event changes four LEDs, so
  it is the one event a single-index paint cannot serve.

**Deps.** #34 (panic live-verify) still wants PLN's hands on the four buttons.

---

## #68 — orphan orbits: name the ghosts before the stage does

**Description.** PLN by ear: loaded vague_de_crime, played bombe_dj, *"heard diam's voice,
now still hearing the synth from crimewave... i hear it regardless of gains, but filters work
on them"*.

**Done** (`3ab9ae0`). `armada/setlist_opal2026.txt` (the set as data, resolved from
backlog codenames by locating each file) + `tools/orphan-orbits.py`
(chain / `--matrix` / `--pair` / `--silence` / `--json`) + 39 tests.

**Learnings.**
- `dN` replaces orbit N and says **nothing** about the others. **Both of PLN's symptoms are
  the proof and are diagnostic in general**: "regardless of gains" because the new track's
  gain lines address only its own orbits; "filters work on them" because gF3 is the global
  CC51 filter applied *inside* the ghost's own still-running pattern.
- **8 of 12 OPAL transitions leave ghosts, 16 orphaned orbits.** The ear-report reproduces
  exactly: vague_de_crime → bombe_dj leaves {6, 10}, and d6 IS the crimewave synth.
- **The find worth more than the report: Perfect <3 declares ALL TWELVE orbits, so its
  matrix column is entirely clean — a free RESET POINT.** Put it after the messiest stretch
  and ghosts stop propagating. A real ordering lever for #12.
- **Cross-validated**: the parser's orbit sets match the ten hand-measured on 2026-07-28.
  Tool and ear agree.
- **Stopped short of automating the fix, deliberately.** `eval-file` calls
  `atom.workspace.open(..., activatePane: true)` — it opens AND activates, so it would yank
  the editor away from the track being played mid-transition. Worse than the ghost.
- `silence`, never `# gain 0`: gain 0 leaves the pattern scheduled, burning a voice, and a
  later global gain change revives it.

**Deps.** Feeds #12 (order) and #13 (transitions). Fix choice needs PLN.

---

## #69 — the tray now says what gear is ALREADY UP

**Description.** PLN: *"i rightclick the perf indicator and see no gear status?"*

**Done** (`8bbcfd9`). A `Gear ▸ 3/6 up` submenu labelled ● running / ○ stopped / ✗ missing,
refreshed on menu-open only. `is_running` rewritten forkless. +11 tests.

**Learnings.**
- The state existed all along (`launchers.is_running`); `_build_menu` ran once at startup
  and read only `available`. **The safe action ("it's already up") looked identical to the
  dangerous one** — two SuperColliders is a zombie port 6010 and a silent rig.
- **THREE worse bugs surfaced while wiring it up, each found by checking the output:**
  1. `ARDOUR_SESSION` pointed at **"Tidal Multi"**, the ARCHIVE, not "Tidal Live". The tray's
     Ardour button opened the wrong session — the same mixup that produced a confidently
     wrong fader report on 2026-07-28, one layer down, 6 days before OPAL.
  2. Candidates were `ardour8/7/6`; the installed binary is `ardour9` (`ardour-9.2.0`). The
     entry reported "unavailable" while Ardour was running on the same machine.
  3. `pgrep -f ardour` matched the `tidal-ardour-autoroute.sh` helper **and any shell whose
     command line mentioned the word**. It could report Ardour UP while it was DOWN — worse
     than reporting nothing, because it is the state you act on. Now matches the executable
     basename, with full-line matching kept only for interpreted tools.
- **No forks:** one `pgrep` per launcher became a single shared /proc scan — 6 items in
  10.7 ms, and `snapshot()` is called by the web Bridge's poll too. Same lesson as the LED
  daemon's per-event fork.
- Glyphs not colour: a tray menu inherits the desktop palette, and this gets read in a dark
  room seconds before playing.

**Deps.** Unblocks #25 (the Bridge as single cockpit).

---

## 2026-07-29 — the day the seed was found to have never run (OPAL D-5)

### #79 — eval produces a SILENT pattern; the 4-cycle xfade masks it as a "drift to silence"
**Description.** PLN, playing: *"ctrl_enter on do it right, i hear the 4-bar pattern
each bar lower, 4th barely audible, its clearly a xfade"*, and on wap *"no bass? when
i move the knob C5 it starts sounding"*. Filed as an xfade bug.

**Done.** Root cause found and fixed in `2648074`. It was neither the xfade nor the
tracks. `pulsar-tidalcycles` splits BootTidal.hs on BLANK LINES, strips the `:{`/`:}`
it finds, and re-wraps each chunk in its own `:{ ... :}` — which holds exactly ONE
statement. The #55 control seed was `:{ let _seed = ... :}` followed by `mapM_` and
`putStrLn` with no blank lines, so all three fused into one statement and died with
a parse error. **The seed never ran once between 2026-07-27 and 2026-07-29.** Fix =
two blank lines, documented in-file as load-bearing. Verified live: the boot now
prints `[BootTidal] seeded 51 LCXL controls (#55)` and PLN confirmed it.

**Learnings.**
- *Every `dN` is `xfade N` with `xfadeIn 4`.* The four bars you hear after an eval
  are the PREVIOUS pattern leaving. A silent eval gets a graceful exit, so the bug
  presents as a drift. **Sound after ctrl+enter is evidence the LAST eval worked.**
- Measured with the new `silent-eval.py`: empty control map → **23 orbits silent
  across 10 of 10 buildable tracks**, including `desire` d1-d6, the entire track.
  With the seed → **zero**. Necessary and sufficient.
- **Why every guard was green.** `check-boot.sh` proved the helper block typechecks,
  the seed block typechecks, and the helpers emit events on an empty map. All true,
  all irrelevant — they test the Haskell; the failure was in how the file is CHUNKED
  AND FED. #61 had already suspected exactly this and was closed on that comfort.
  *Verifying that code is correct is not verifying that it runs.*
- Two hypotheses were killed from a FILE with no rig and nobody's ears, by the
  gig-log `controls` view: DJ filters parked at 51/59/80 (open) and gMask/gMute all
  at 0. That is the value of a parked-state readout.
- Guard added (`check-boot-blocks.py`, check-boot pass 4): replays Pulsar's chunking
  bug-for-bug — including that JS `.replace(str, str)` is NON-GLOBAL — and fails on
  parse errors only. Negative-tested: names block 26 at BootTidal.hs:663.

**Deps.** Blocked #77, #74, #13 — all now unblocked. Re-closed #61 with the real
answer.

### #82 — gig-log report: show each control's LAST VALUE
**Done.** `d1b21c9`. `left at` column, a `controls` subcommand translating each
parked value into what it MEANS, and `report --from/--to` windowing.
**Learnings.** Windowing has one non-obvious requirement: xrun/throttle are RUNNING
TOTALS, so a naive slice reports the whole session's count inside the window —
wrong, and wrong high; `slice_session()` rebases. Transcribing `gDJF` for the Hz
column surfaced that the lowpass half is LINEAR IN HERTZ, so the knob feels inert
then collapses in its last 2% — one gesture producing both "went quiet" and "sounds
lpf'd", the exact symptom pair #79 was filed with.
**Deps.** The keystone that unlocked #79.

### #78 — unmapped knobs must be DARK
**Done.** `2acbd9c`. Published current-track file; `--map` publishes as well as
paints; `--watch` follows it at 1 Hz and repaints.
**Learnings.** The colour code was never wrong — `build_frame` already paints only
bound CCs. The lie was WHICH BINDINGS it was handed: the watcher ran with no track,
so it painted the convention board (40/40 lit) regardless of what was loaded.
Measured: `do_it_right` binds 19/40, so **21 dead knobs were glowing**; wap 15;
desire 18. **The value ramp did not create the problem — it made a pre-existing lie
legible.** When a cosmetic change surfaces a complaint, check whether the cosmetics
exposed bad DATA before changing the cosmetics back.
**Deps.** Left #84 (publish from Pulsar's ctrl+enter path) as the honest remainder.

### #8 — THERMAL: 17024 package-throttle events
**Done.** Answered from the gig-log recording, no new work needed. **0 core and 0
package throttles** across 10h48m with audio, 72 °C peak. Heat is exonerated; the
17024 was an absolute counter including power excursions.
**Learnings.** Counters are deltas, not totals — and *measure the set, not the
corpus*: PLN corrected that only ~90 min of that session was playing, so every
aggregate over the full 10h48m described the wrong thing. That correction is what
motivated `report --from/--to`.

### #81 — "gig-log recorder is DEAD"
**Closed INVALID. The premise was false and the error was mine.**
**Learnings.** I asked systemd about `parvagues-gig-log`, a name I GUESSED; the unit
is `gig-log.service`, and it had been up 11 hours. A wrong unit name returns
"inactive", indistinguishable from a dead service. I then used that false negative to
resolve the report's deliberate ambiguity (*"still running, or it was killed"*) the
wrong way — **a broken check settling the very question it could not answer**. Two
lessons: read identifiers, don't guess them; and a tool must not offer the reader a
choice it can make itself. `a4dc37b` makes the report decide from DATA RECENCY (not
a process match, since `pgrep -f` has its own false-positive problem) and print
either `● RECORDING NOW` or `the recorder was KILLED`.

### #50 / #52 — LED colour tail (archived late, 2026-07-29)
Two small completed LED tasks that never got an archive entry and were cluttering
the board. Recorded here for completeness rather than value.

**#50 — Value-reactive LED colour for gF1/gF2/gF3 (now that DJF audio is fixed).**
A duplicate of #48, filed when the DJF audio bug was fixed and it became worth
doing "for real". Superseded by the settled colour language in #11/#76 and by
`81aecc9`, which corrected the report to model BOTH filter halves — see the
`reference_djf_two_halves_and_event_lag` memory: hard right is an 8 kHz high-pass,
not "open", so any value-reactive colour for row C must be bipolar.

**#52 — Fix button-LED reverting to factory yellow on repeat press.**
The "factory yellow" was never the device's default: it was a real Midi-Through
feedback loop echoing note velocity 127 back as an LED colour byte
(`reference_lcxl_midi_feedback_loop`). It regenerates on a Tidal reboot, so the
symptom returning is not a regression in the LED code.

---

## #85 — LED watcher had three lifecycles and two could run at once
**Description.** The board's colours only persist because a daemon holds a model of
every control and repaints from it. That daemon had no home.

**Done.** `tools/lcxl-leds-watch.service`, symlinked into `~/.config/systemd/user/`
the way `parvagues-bridge.service` already is, enabled, `WantedBy=default.target`
so it starts at boot with linger. `gig-up.sh` no longer spawns anything — it
`systemctl --user restart`s the unit. Commit `901b43a`.

**Learnings.**
- Three ways to start it existed: by hand (dies with the terminal), `gig-up.sh`'s
  own `setsid` copy, and a *transient* `systemd-run` unit. The second and third
  could run **simultaneously**, two processes writing SysEx to one LCXL, fighting
  over every LED — and neither wrong enough to look broken, which is the worst kind
  of bug this rig produces. `restart` (not `start`) guarantees one owner even after
  a crash.
- **`StartLimitIntervalSec=0` belongs in `[Unit]`, not `[Service]`.** In `[Service]`
  systemd logs "Unknown key … ignoring" — a warning nobody reads — and the default
  5-restarts-in-10s limit stays in force. With a hot-pluggable LCXL usually absent
  at boot, the unit would burn its retries and sit in `failed`, board dark for the
  session. A silent failure one section heading away from working.
- Cost, from the transient unit's own accounting: **2.140 s CPU over 1 h 53 m =
  0.03% of a core**, 14.5 M RSS. The fork-per-LED is a throughput problem, not a
  load problem — so never a suspect when hunting xruns.
- `pgrep -cf 'lcxl-leds.py --watch'` reported **2** with one process running: it
  counts the shell running the pgrep pipeline. Read the process list, not the count.

**Deps.** Unblocked the LED work that followed (#78 tail, #94).

---

## #88 / #89 — the Ardour re-learn: faders 1-8 = d1-d8, knobs A1-A4 = d9-d12
**Description.** Make the surface legible: fader N levels orbit N.

**Done.** PLN re-learned all 12 bindings by hand; verified from the session file —
`CC 77..84` → Tidal 01..08, `CC 13..16` → Tidal 09..12, **nothing stray**, and
`CC 20`/`CC 31` released. `check-mix` then read **OK for the first time**: all 12
orbits reach master, Tidal 01-08 within +0.0..+1.1 dB. The two track-side conflicts
(#88) were fixed in `0538eb9`.

**Learnings.**
- **The first "saved" wasn't saved.** The session file's mtime hadn't moved and the
  bindings were byte-identical to before. Verifying by *reading the artifact* rather
  than trusting the report caught it in seconds; the fix was Session → Save from the
  menu rather than ctrl+S with focus elsewhere.
- **The physical LCXL faders were all at 0** while PLN had raised the Ardour faders
  with the mouse. The LCXL has no motors, so the two diverge silently and the *next
  brush of a fader* snaps the orbit to the hardware position — i.e. silence. A whole
  class of mystery silence, found in `gig-log`'s surface view, not by ear.
- **CC 77 was bound to nothing**, despite the standing belief that it was a global
  gain whose fall killed everything. Beliefs about bindings must be re-read from the
  session, never remembered.
- `^14` was simultaneously Ardour's Tidal-10 gain and `piment_bresilien`'s d10
  crushbus — one knob, two unrelated jobs, the "rig is haunted" signature.

**Deps.** #46 → #92 → #94.

---

## #92 — column-align the OPAL set (phase 1: knobs)
**Description.** PLN spotted the half of #46 I had not measured: *"we need the
coverage of the effects moving in the tracks, e.g. bass from 81 to 80 means effects
on 53 now move to 52"*.

**Done.** `tools/surface-columns.py` (price it) + `tools/migrate-columns.py` (perform
it). **59 knob substitutions across 13 tracks, 5 gSel FIXMEs, 0 collisions.** Commits
`d8fb13b`, `d8fd702`, `0538eb9`, `fdde092`, `346c652`.

**Learnings.**
- My earlier "two track edits" costing answered the **wrong question** — it looked for
  CC *collisions*, but the property that makes a surface readable is **column
  coherence**. 19/84 orbits were aligned; the corpus was **uniformly off by one**
  (d1→col2 … d6→col7, d7/d8 already right), because columns 1-3's C-knob and buttons
  were already the family filters and mutes, so per-orbit controls had been pushed
  right to dodge them.
- **Because it's uniform, there is no fader mapping that makes today's tracks
  coherent.** Either the tracks move or the surface stays a lookup. PLN's instinct
  that the re-learn was only half the job was exactly right.
- **34 "destination taken" clashes were phantom** — an artefact of costing moves one
  at a time. Each orbit wants the slot of the orbit below it, free *once that one
  also moves*. Build the whole permutation, then write once. Sequential application
  would have corrupted the files; `desire` needs two genuine **swaps**.
- **Scope the rewrite per orbit, never file-wide.** `desire`'s `^19` drove both d7
  and d9 and they migrate apart; a global replace would have fused two gestures.
- **Never rewrite comments.** A commented `^NN` is an alternative PLN re-enables
  mid-set; renumbering it breaks weeks later with no trace.
- **A measurement overruled my design.** I proposed `gF1..gF8`, one filter per
  column. Measuring which orbits receive each showed gF1→d1/d2/d3/d8, gF2→d4,
  gF3→d5/d7 — **per-family, corpus-wide, deliberate**. Per-orbit filters would have
  destroyed a working abstraction. C1/2/3 stay put, and C4-C8 stay free, which also
  removed the "one effect per orbit" constraint I'd wrongly imposed.
- **My own tool had a parser bug that closed doors**: the helper-CC scan counted CCs
  appearing only in `--` comments, inflating the "unavailable" set by three. A wrong
  number that *closes* options is worse than one that opens them, because nobody goes
  looking. Fixed by stripping comments, as pvlint and silent-eval already do.
- **The verification lens had to be narrowed to match the work done**: after applying,
  `surface-columns` still reported 11 moves for `bombe_dj` — all buttons, deliberately
  out of scope. A gate that reports deferred work as failure is a gate you learn to
  ignore. Hence `--knobs`.

**Deps.** #46; blocks #94 (buttons, 166 refs, now unblocked by gMask's retirement).

---

## #93 — silent-eval harness cannot BUILD 3 of 13 setlist tracks

**Description.** Found while verifying the #92 column migration, 2026-07-29. Two of
the harness's own build failures (ambiguous `cutoff`, ambiguous chord-literal
`IsString`) had been sitting there, mislabeled as "harness limit, ignore this" — a
message that turned out to be actively dangerous.

**Done.** Two real harness bugs fixed by matching ghci's actual defaults instead of
diverging from them: hide every top-level name the generated module defines from the
`Context` import (in ghci a `let` shadows an import silently; at module top level
there is no shadowing, so the fix is to make hiding explicit and derive the hide-list
from the generated text so it can't drift); turn on `ExtendedDefaultRules` +
`NoMonomorphismRestriction`, both on by default in ghci. 12/13 tracks now build.

The verdict computation changed too: build errors whose GHC line number falls inside
the track's own generated lines now get a distinct `BROKEN` verdict — "THE TRACK DOES
NOT COMPILE — it will fail live too" — and count as a gig blocker; errors in the
harness scaffolding stay `BUILD FAILED — harness limit`. Line numbers are mapped back
to the real `.tidal` file by matching the offending line's text.

**Learnings.**
- **The distinction between "our fault" and "your fault" must be computed, never
  asserted as a constant string.** The harness's own reassuring message was right
  twice and catastrophically wrong the third time — it told the reader to ignore a
  real, gig-blocking bug in `desire.tidal` (#108) using the exact same words it used
  for its own limitations.
- **Two apparently-separate build failures had the same root cause**: the harness
  environment diverging from how ghci actually evaluates the same code. Fixing the
  divergence generically (match ghci's defaults) resolved both, rather than patching
  each symptom (annotate this one literal, rename that one binding).
- **The 13th failure wasn't a harness bug at all.** Once the two real harness bugs
  were fixed, the last one turned out to be a genuine typo committed in
  `desire.tidal` — see #108.

**Deps.** Found via #92. Unblocks accurate pre-gig verification of the whole setlist
for the first time. Directly produced #108 (gig blocker found).

---

## #106 — Per-take sidecar: the MIDI performance must travel with the audio
## #107 — Take archive: wavpack compression

**Description.** PLN, 2026-07-29, recording OPAL practice: *"ensure recordings have
the midi too, keep this from blowing up tracking either ardour rec or compressing to
avoid storing hours of no-move?"*. Two asks, one command.

**Done.** `tools/take-lens.py pack <take> [--compress]`. Sidecars a take's MIDI
(every cc/note/track/mark record in the window, copied out of the gig log before its
prune timer deletes them), meta (window, per-orbit peaks, xrun delta, gear/boot
state), and EDL, next to the audio rather than in a directory nothing else protects.
`--compress` then `wavpack -h`s every source and verifies EACH with `wvunpack -vm`
before reporting bytes saved. Measured on take 94 (21 min, 12 orbits): **5.84 GB →
1.25 GB, all verified lossless.** Never deletes the `.wav` — that's a separate,
explicit, human step after the take has been heard.

**Learnings.**
- **`xrun` in gig-log's `s` records is a running total, not a per-tick delta** — the
  same `CUMULATIVE` trap the log format documents in its own code, walked into again
  by a new consumer. Summing it reported 58,668,469 xruns for a 21-minute take.
  Rebased (last minus first): 230, a real and useful number.
- **Records != events.** A `cc` record is a one-second coalesced bucket carrying a
  count `n`; reporting the record count instead of `sum(n)` understated the
  performance ~25x.
- **FLAC was rejected outright, not benchmarked against.** It can't store 32-bit
  float, and routing through s32 would hard-clip the 8 orbits sitting over 0 dBFS
  while looking like a clean win on the one silent orbit. A format that can silently
  destroy the loudest material is not a candidate, regardless of ratio.
- **Found in passing: the LED daemon was silently mute in its own journal.** Two
  independent causes — Python block-buffers stdout to a pipe (always true under
  systemd, fixed with `PYTHONUNBUFFERED=1`), and its own `-q` flag was suppressing
  the one line that mattered (track changes) rather than the per-CC chatter it was
  meant to quiet. `quiet` needs to mean "no spam," not "no signal."

**Deps.** Pairs together by design (one `pack` run does both). #106/#107 depend on
nothing; the trim-threshold fix ([[feedback_trim_threshold_ear_correction]] in
memory) landed the same evening on the same tool.

---

## #31 — desire.tidal bass rhythmics study

**Description.** PLN, 2026-07-29: *"i gotta solve desire bassline is not great
rythmics coulld be cooler more nto more ete_a_mauerpark or insouciance or
haunted_house i guess. letting it cool."* — pointing at his own prior tracks as the
target feel, then deliberately stepping back from the decision.

**Done.** `study/desire_bass_rhythm.tidal` — read all three referenced `.tidal`
files, named the current line's mechanism precisely (four equal `.`-groups, one
repeated note, density-only variation via `*<n>`, no rests, strictly on-grid — "a
metronome with a stutter"), extracted the six mechanisms the references share and
desire has none of (uneven durations via `@`, pitch=rhythm, eighth-note calage
displacement, accent following the rhythm's own weighting, rests, a phrase longer
than one bar). Wrote four alternatives, each isolating a different subset of
mechanisms so the reaction is attributable, verified via `silent-eval --seeded` to
compile and measurably differ (3.0/4.0/4.5/5.75 onsets per bar vs ~5.5 today). Pitch
design (D minor, measured corr=0.903) held constant throughout.

**Learnings.**
- **The densest option (D, 5.75/bar) is the control that matters most.** It's
  *busier* than the current line and still reads as a riff — proving the original
  complaint was never about note count, it was about where notes fall and how long
  they last. Without that control, "add some space" would have felt like a guess
  dressed as a diagnosis.
- **Different shape from the copycat/algoraoke workflow.** That one matches an
  EXTERNAL track via web research and demucs. Here the references are the artist's
  own prior work already in the repo — the method is read the actual source, name
  the current mechanism precisely, extract the shared grammar, hand back isolated
  options. See `feedback_self_reference_rhythm_study` in memory.
- **"Letting it cool" is an instruction, not a delay.** It means prepare options
  without pushing a decision — the study never touches his dirty working copy.

**Deps.** Depends on nothing. Surfaced #108 as an unrelated finding (desire.tidal
doesn't currently compile at all — a one-character typo, not a rhythm issue).

## #108 — GIG BLOCKER: desire.tidal does not compile

**Description.** `live/collab/raph/desire.tidal:46` carried `# pan 0.42plz /se` — a
committed typo, not a working-tree accident. The file has no blank lines, so Pulsar
treats it as ONE do-block: that single stray word killed every orbit in a Finale-slot
setlist track, six days out from OPAL.

**Done.** Fixed by PLN in his own buffer; verified 2026-07-30 (`grep plz` → 0 matches)
and committed as part of ce887b7. `silent-eval --seeded` now reports **13/13 ok** — the
first time the entire OPAL setlist has built cold.

**Learnings.** The bug was invisible for as long as it was because silent-eval printed
every build failure as `BUILD FAILED — harness limit, NOT a track verdict`, a *constant
string*. It was right about that twice (two genuine harness bugs) and catastrophically
wrong the third time, telling the reader in reassuring words to ignore a broken setlist
track. Fixed by computing the verdict from which line GHC pointed at rather than
asserting it. **Any "don't worry, this is our fault not yours" message a tool prints
must be reached by evidence** — the dangerous side to be wrong on is the side that says
"ignore this", because that is the side nobody re-checks.

**Deps.** Found by #93's harness repair. Unblocked nothing formally; it was simply the
difference between a set that plays and one that doesn't.

## #70 — you_my_sunshine: d5 and d11 share cut group 5

**Description.** pvlint PV004 flagged, on its first real run (2026-07-28), that d5 (the
vocal) and d11 (`no_sunshine:4/4` chopped) both carried `# cut 5`. Cut groups are
monophonic, so each d11 event killed the vocal mid-word.

**Done.** 2026-07-30, commit 27c1581 — d11 → `# cut 11`, following the
group-N-equals-orbit-N convention d3/d6/d8/d9 already used in that same file. Left
reversible in one edit, with the reason in a comment, because "both vocal layers are
deliberately monophonic" was a legitimate arrangement PLN could have wanted.

**Learnings.** The find worth keeping is not the fix but the *correspondence*: d5's line
59 carried PLN's own `-- FIXME VOICE OFF`. **Same bug, two labels** — one filed by a
linter, one written by ear, months apart, neither connected to the other until both were
read side by side. They did not look like duplicates in a task list because **the ear
names the SYMPTOM and the linter names the MECHANISM.** Generalisable: when a lint
finding and a human FIXME live in the same file, check whether they are one defect before
treating them as two items.

**Deps.** Found by pvlint (#100's rule set). Closed one of #111's Group-C markers as a
side effect.

## #105 — Align the gM family: mutes by ROLE, filters by BLOC

**Description.** The corpus treated `gM<N>` as a mirror of `gF<N>` — a legacy of gM once
being `gMask . gMute<N>` before gMask was retired. PLN wanted the kick mutable on its own
button.

**Done.** 2026-07-30, commits 72943be (tooling) + 27c1581 (corpus). **917 lines across
213 files**, corpus-wide rather than setlist-only at PLN's correction (*"not only in the
set am i right?"*), plus 7 setlist orbits that had NO mute given one (*"no mute is
oversight!"*). Map authored once in `tools/lcxl_grid.py` (`_FILTER_FAMILY` /
`_MUTE_FAMILY`), migrator `tools/fix-mute-roles.py`, guarded by new pvlint PV012/PV013.
Non-set missing mutes reported, not written: `docs/2026-07-30-orbits-without-a-mute.md`.

**Learnings.** Three, and the first is the expensive one.

1. **The task's own stated invariant was wrong.** It was framed as "align gM index to gF
   index". I measured 35 set mismatches against that rule, rigorously, and was ready to
   migrate. PLN's actual map groups filters and mutes DIFFERENTLY on purpose — you sweep
   the drums as a bloc but drop the kick alone — so the real edit count is 55, and
   `d4: gF2+gM3`, which the mirror rule called broken in 11 of 13 tracks, is CORRECT.
   Shipping it would have moved 11 good lines and left the kick sharing a button with the
   hats: the exact complaint that reopened the task. **A stated invariant is a hypothesis
   about intent, and a rigorous measurement against the wrong invariant produces a
   confident number answering the wrong question.**
2. **The linter audited the migrator, immediately.** PV013 was written to prevent future
   drift; its first catch was the migration in progress. `fix-mute-roles.py` had its own
   orbit regex requiring `^dN $` and had walked straight past `d5  -- The Voice of Love`
   (no `$`, gates on a commented continuation). It now imports pvlint's parser. The miss
   had hidden 14 rewrites and 7 missing mutes — **under-application, not corruption,
   while every check read green**: convergence said 0 remaining, silent-eval said 13/13,
   222 tests passed. Verifying the output cannot catch a tool that can't see part of its
   input; only a second independent parser can.
3. **The verification that actually answered PLN's "ensure all tracks playable".** Not
   spot-checks: (a) convergence — run twice, second pass proposes 0; (b) a structural
   proof diffing all 917 changed lines against HEAD and asserting both sides are
   identical once mute tokens and whitespace are stripped — 908 provably token-only, the
   other 9 individually enumerated and accounted for; (c) `silent-eval --seeded` over all
   213 touched files on a pre-migration worktree AND the migrated tree, **verdict lists
   byte-identical**. That last one is the real answer, and it cost ~10 minutes of
   unattended compute.

   It also surfaced #112 as a by-product: 14 corpus tracks that do not compile at all,
   pre-existing, none in the setlist.

**HONEST LIMIT, recorded because a green check here misleads.** Every button CC seeds to
0 and `gMute<N>` is `midiOn "^7N" (mask "f*16")`, so all three mutes are equally inert
until pressed. None of the verification above says anything about behaviour UNDER a
press — the only thing this change alters. Left for PLN's fingers.

**Deps.** Closed #70 in the same diff. Feeds #44 (`--check` as a pre-gig gate). Related
to #54/#98, the same "the surface should be one sentence" thread.

## #109 — Stale orphan-orbits fixture (you_my_sunshine d7→d6)

**Description.** `tools/tests/test_orphan_orbits.py:201` expected d7; the parser said d6.

**Done.** 2026-07-30. Fixture corrected to `{1,2,3,4,5,6,8,9,11}`. Cause was PLN's
d7→d6 consolidation (with ^91→^90, ^59→^58), the same move he had made in do_it_right.

**Learnings.** Re-derived the set by reading the file's column-0 declarations and
checking for commented-out ones, NOT by pasting the parser's output. **A hand
measurement that quotes the thing it checks is a tautology** — the whole value of the
fixture is that it is an independent witness. Cheap to get wrong, and the wrongness is
invisible forever after.

**Deps.** Blocked nothing; it was the difference between a suite at 213/1-fail and a
clean baseline to migrate against, which is why it went first.

## #62 — Ardour faders: the SAVED session and the LIVE mixer disagree

**Description.** `check-mix.py` kept reporting Tidal tracks at -inf dB in the saved
Ardour session while the live mixer sounded fine. The class of bug that cost PLN a whole
evening on 2026-07-27, when "d7 fades to nothing" and "d9-d12 make no sound" were chased
through Tidal, SuperDirt and PipeWire before anyone looked at the fader.

**Done.** Closed 2026-07-31 by PLN raising Tidal 10 and saving. `check-mix.py` reports
no failing orbits.

**Learnings.** Three things, and the middle one is the reusable one.

1. **Reframing moved it.** On 2026-07-30 it read as "3 faders are down" and sat mid-board.
   Cross-referencing the silent tracks against the orbits each setlist track actually
   declares (pvlint's parser, not a grep) turned it into **"9 of your 13 tracks would
   lose at least one orbit on a relaunch"** — and it became the top item. The measurement
   didn't change; the denominator did. Worth doing to any infrastructure finding before
   deciding it's minor.
2. **A save splits the diagnosis in two, and you only see it if you re-measure AFTER.**
   PLN's Ctrl+S fixed Tidal 06 and 12 but *not* 10. A fader still at -inf after a save is
   not an unpersisted fix — its live value is genuinely down. So there are two remedies
   hiding under one symptom: "Ctrl+S" for a fader that disagrees with the desk, "raise
   this fader" for one that survives a save. Re-running the check after the fix is what
   separates them; without it, the second class hides behind the first indefinitely.
3. **It took three sessions with a different set of tracks each time** (07-28: 05/06/08/10;
   07-30: 06/10/12; 07-31: 10). Root cause is structural — the D-row faders are
   MIDI-learned to CC 77-84, so the physical desk and the session file drift
   independently, in both directions. That is why it must become a hard fail in the #44
   pre-gig gate rather than a thing to remember.

Never fixed from outside: writing faders desyncs them from the physical desk, CC77 is
global gain where down is total silence, and levels are musical intent. The ask was
always one keystroke, and framing it that way is what got it done.

**Deps.** Feeds #44 (must be a hard fail). Was the top blocker on trusting any launch.

## #113 — Set coherence review: unmapped effect/button slots across the set

**Description.** PLN, walking the OPAL set by eye: *"i saw some unmapped effects/button,
a 'set coherence mapping review' and quick wins suggestions (bass octersub, lead slice,
etc etc) could be good ways for you to fill-in suggest"*.

**Done.** 2026-07-31, commit a32c6d2. `tools/set-coherence.py` +
`docs/2026-07-31-set-coherence.md`. **108 idle slots across 117 declared orbits.**

**Learnings.**

- **Absence has no line number.** You cannot grep for a control that isn't there. The
  only way to see an unused affordance on a fixed grid is to compute `OWNS − USES` per
  orbit — `lcxl_grid.orbit_home(n)` for the cells, pvlint's parser for the block, `"^NN"`
  refs for usage. Generalises to any "what's missing" question over a known schema.
- **Report both directions of a coherence question**, because fixing one creates the
  other: IDLE (owns a slot it never uses) was uncovered until now; STARVED (drives more
  than it owns) was already PV008 + the FIXME(#54)/(#94) markers.
- **Three systematic findings beat a flat list of 108.** (a) The drums' own effect knobs
  are the deadest cells on the board — d2.fx idle 12/13, d1.fx 9/13, d3.fx 8/13, and
  since d1-d3 own exactly ONE per-orbit knob each this is their only knob, unused; the
  kick has no per-orbit effect knob bound anywhere in the set. (b) Row A (d9-d12, A5-A8)
  ~90% idle, which is #98's pooling proposal with its evidence attached. (c) d6 has gates
  but no knobs in 5/5 tracks — fallout from the d7→d6 consolidation, where ^58/^90 came
  along and ^34/^54 did not. **An orbit renumbering carries the bindings it names and
  silently drops the ones it doesn't.**
- **The finding that inverted the ask.** Measured what PLN actually binds to a per-orbit
  effect knob — perc `legato`(4); bass `lpf`(3), `gain`, `bandf`; lead `width`(2),
  `modIndex`, `filterRange`, `cutoff`, `resonance`; kick nothing — and his two *named
  wants sit outside his own idiom*: he binds lpf to bass knobs and never octersub, and
  slice lives on his buttons not his knobs. So "bass octersub, lead slice" are additions
  he is reaching for, not drift being corrected. Telling him that is more useful than
  pretending the corpus implied them, and it is a step worth adding to
  self-reference work generally: check whether the user's own request matches their own
  history before treating it as a restoration.

Nothing applied — every suggestion changes how a track sounds and he rehearses on these
files. Also confirmed the #94 remap held: borrowed CCs are 0 or 1 across every track.

**Deps.** Confirms #98 with data. The IDLE counterpart to #54's STARVED problem.
