Lane 3: Settings — Discord, and Recording, retention & AI (#729, CAMP-12, CAMP-13) #776

Merged
claude-bot merged 3 commits from build/lane3-settings-b into main 2026-09-23 16:59:09 +00:00
Collaborator

The second of three Settings PRs: Discord (CAMP-12) and Recording, retention & AI (CAMP-13) — the biggest section in the cupboard. Five of nine sections are now built; the built flag in settingsSections.js carries the rest.

The cap (D24) meets a GM, which is the part to read

#763 built the server. This is the first surface where a GM sees it.

  • IS-312's three states are all built and tested, including the one a naive build misses — "This instance keeps recordings 30 days. ⚠ 180 days is as long as it allows — an administrator can raise that." The test reaches that state from a number the GM types, not only from the stored one.
  • CA-073's "Keep it" is absent under a cap, present without one, asserted both ways. The same rule is generalised to transcripts' "Keep them" — the drawing only ever draws that row on an uncapped instance, so the rule was never visible there, but offering "keep the words for good" under a retain_months cap would be offering something the server is about to refuse.
  • An absent cap changes nothing. retain_indefinitely on both sides offers all four modes and a test asserts the round trip with no question asked.
  • The refusal reaches the reader, verbatim, with the 365 the GM typed still in the box. Nothing is clamped in the page — the service clamps on read, which is a different thing and deliberate.

One correction to my own brief

I told the delegate to use settings_service's ordering helpers rather than re-derive the order. Those are Python and cannot be imported into a page, and Privacy.jsx has already faced this and deliberately refuses to copy an ordering into JS ("a second copy of it here is exactly the drift").

What it did instead is better than either: "at the cap" is an equality of pairs, never a comparison; and "did this get shorter?" is derived from the thing the setting is actually about — a cutoff instant, where bin-on-success is now, N days is now − N, and for-good is never. That reproduces the server's order by construction rather than restating its rank table, and it catches the case a number-only model misses: 90 days → "bin it once transcribed" is a shortening with the same figure. Used for one thing only, deciding whether to ask a question; the server still refuses over-cap writes and the sweep still reads its own comparator.

D36: the drawing draws a dead button, and this does not

The drawing puts a bare disabled on the channel test when the ID is malformed. That is precisely what D36 forbids. Both tests are aria-disabled with a visible, aria-describedby-linked reason ("Fix the channel ID first.", "There is no webhook to test yet.") and stay in the tab order.

IS-395/D67 is said twice over: in the blank-server hint as drawn, and as a banner when both are off — "Nothing is posted anywhere… There is no server-wide fallback: an instance's Discord settings never post for a campaign" — which appears the moment a GM presses "Remove it", before saving, which is when they need it.

Two rows built partially, both because the server cannot answer honestly

IS-314 (never set up / switched off / switched back on). GET /api/campaigns/{id}/ai-settings is 403'd by the very switch D25 is about, so while per-campaign AI is off the page cannot read this campaign's stored endpoints — or even whether it has any. The drawing's kv list of the GM's actual summariser and transcriber is therefore not built; the first two states are one panel, worded to be true of both. Caching the values in localStorage and claiming them back was rejected: a stale claim about somebody's stored API key is a worse failure than a sentence that covers two cases. Follow-up filed.

IS-313 (what a sweep would take). No endpoint previews one, and SessionListItem carries nothing about whether a session still holds audio or a transcript. So the count is of sessions old enough to be caught — which is exact — and what they still hold is hedged. The drawing's "Three sessions have audio older than that" would have been a guess, and has_summary over-counts, since a summary outlives its transcript by design. Follow-up filed.

Where else the drawing and the server disagreed

  • The route is /settings/recording, not /settings/ai. My contract said ai; the merged frame says recording, and the id is the address segment. Paths are contracts, so the merged code won.
  • The drawing gives CAMP-13's campaign settings no Save button at all — only the AI panel gets one. That looks like an oversight: the toggles and the retention pair are a form, IS-313's confirmation hangs off saving them, and D22's guard needs something to be dirty against. The page has its own Save/Cancel; the panel keeps its own (different endpoint, a PUT, and CA-091 names it), labelled "Save the models" so two save buttons never share an accessible name.
  • The Server field holds an ID, not a name. guild_id is validated digits-only and nothing may call Discord from the backend to resolve a name. The drawing's "The bot is in this server" is dropped — nothing in the app can know that — for what the field actually decides.
  • The webhook is never drawn, following the drawing and #671's reasoning that it is a credential. Blank preserves, "Remove it" clears. An untouched box is omitted from the PATCH body entirely, so a save can never blank a credential nobody meant to touch; there is a test for exactly that.
  • Session titles, not numbers, in the shortening question — D86 gave sessions a number but there is no column yet (#760), so "Sessions 8, 10 and 11" is unbuildable.

What the render caught that reading the code did not

Both prototype sections in both themes, and the real built pages from a stub API, dark and light, including the shortening modal and the D25 panel. Four faults, all invisible in the markup:

  1. Rows inside a Section lost the form's rhythm — .form's grid gap falls between its children, which on this page are the sections, so every hint ran into the next label.
  2. The retention strips were crushed — .seg is flex and shrank inside .ret__pick, wrapping "Bin it once transcribed" onto two lines and "The instance's" into a column one word wide.
  3. "Recordings: 3 sessions are old enough too" read wrong in the audio-only shape, where nothing was named before it.
  4. The D67 banner read as the Server field's error — full-bleed and flush against the first label.

A fifth — a placeholder printed where the instance default goes, when that read fails or is in flight — was caught by a unit test first.

Checks

Frontend 1755 tests in 133 files (81 new), green. npm run lint 0 errors, 234 warnings — identical to the baseline; neither new file contributes one. npm run build clean. coverage/lane2b.py prints with no unclassified rows and no classification changed.

Nothing depends on the wall clock: fixture sessions are dated 2019 (older than any window the file sets, and still older in ten years) or carry confirmed_time: null. The "shortening that catches nothing" case is a 9,000-day window over a 2019 session — genuinely a shortening, and catches nothing until 2043.

A player may not open either section: absence asserted, not a disabled control.

🤖 Generated with Claude Code

The second of three Settings PRs: **Discord** (CAMP-12) and **Recording, retention & AI** (CAMP-13) — the biggest section in the cupboard. Five of nine sections are now built; the `built` flag in `settingsSections.js` carries the rest. ## The cap (D24) meets a GM, which is the part to read #763 built the server. This is the first surface where a GM sees it. - **`IS-312`'s three states** are all built and tested, including the one a naive build misses — *"This instance keeps recordings **30 days**. ⚠ **180 days is as long as it allows** — an administrator can raise that."* The test reaches that state from a number the GM **types**, not only from the stored one. - **`CA-073`'s "Keep it" is absent under a cap, present without one**, asserted both ways. The same rule is generalised to transcripts' **"Keep them"** — the drawing only ever draws that row on an uncapped instance, so the rule was never visible there, but offering "keep the words for good" under a `retain_months` cap would be offering something the server is about to refuse. - **An absent cap changes nothing.** `retain_indefinitely` on both sides offers all four modes and a test asserts the round trip with no question asked. - **The refusal reaches the reader**, verbatim, with the 365 the GM typed still in the box. Nothing is clamped in the page — the service clamps on *read*, which is a different thing and deliberate. ### One correction to my own brief I told the delegate to use `settings_service`'s ordering helpers rather than re-derive the order. **Those are Python** and cannot be imported into a page, and `Privacy.jsx` has already faced this and deliberately refuses to copy an ordering into JS ("a second copy of it here is exactly the drift"). What it did instead is better than either: *"at the cap"* is an **equality of pairs**, never a comparison; and *"did this get shorter?"* is derived from the thing the setting is actually about — a **cutoff instant**, where bin-on-success is *now*, N days is *now − N*, and for-good is *never*. That reproduces the server's order by construction rather than restating its rank table, and it catches the case a number-only model misses: 90 days → "bin it once transcribed" is a shortening with the same figure. Used for one thing only, deciding whether to ask a question; the server still refuses over-cap writes and the sweep still reads its own comparator. ## D36: the drawing draws a dead button, and this does not The drawing puts a bare `disabled` on the channel test when the ID is malformed. That is precisely what D36 forbids. Both tests are `aria-disabled` with a visible, `aria-describedby`-linked reason ("Fix the channel ID first.", "There is no webhook to test yet.") and stay in the tab order. `IS-395`/D67 is said twice over: in the blank-server hint as drawn, **and** as a banner when both are off — *"Nothing is posted anywhere… There is no server-wide fallback: an instance's Discord settings never post for a campaign"* — which appears the moment a GM presses "Remove it", before saving, which is when they need it. ## Two rows built partially, both because the server cannot answer honestly **`IS-314` (never set up / switched off / switched back on).** `GET /api/campaigns/{id}/ai-settings` is **403'd by the very switch D25 is about**, so while per-campaign AI is off the page cannot read this campaign's stored endpoints — or even whether it has any. The drawing's `kv` list of the GM's actual summariser and transcriber is therefore not built; the first two states are one panel, worded to be true of both. Caching the values in localStorage and claiming them back was rejected: a stale claim about somebody's stored API key is a worse failure than a sentence that covers two cases. Follow-up filed. **`IS-313` (what a sweep would take).** No endpoint previews one, and `SessionListItem` carries nothing about whether a session still holds audio or a transcript. So the count is of **sessions old enough to be caught** — which is exact — and what they still hold is hedged. The drawing's "**Three sessions have audio** older than that" would have been a guess, and `has_summary` over-counts, since a summary outlives its transcript by design. Follow-up filed. ## Where else the drawing and the server disagreed - **The route is `/settings/recording`, not `/settings/ai`.** My contract said `ai`; the merged frame says `recording`, and the id *is* the address segment. Paths are contracts, so the merged code won. - **The drawing gives CAMP-13's campaign settings no Save button at all** — only the AI panel gets one. That looks like an oversight: the toggles and the retention pair are a form, `IS-313`'s confirmation hangs off saving them, and D22's guard needs something to be dirty against. The page has its own Save/Cancel; the panel keeps its own (different endpoint, a PUT, and `CA-091` names it), labelled **"Save the models"** so two save buttons never share an accessible name. - **The Server field holds an ID, not a name.** `guild_id` is validated digits-only and nothing may call Discord from the backend to resolve a name. The drawing's *"The bot is in this server"* is dropped — nothing in the app can know that — for what the field actually decides. - **The webhook is never drawn**, following the drawing and #671's reasoning that it is a credential. Blank preserves, "Remove it" clears. An untouched box is **omitted from the PATCH body entirely**, so a save can never blank a credential nobody meant to touch; there is a test for exactly that. - **Session titles, not numbers**, in the shortening question — D86 gave sessions a number but there is no column yet (#760), so "Sessions 8, 10 and 11" is unbuildable. ## What the render caught that reading the code did not Both prototype sections in both themes, **and** the real built pages from a stub API, dark and light, including the shortening modal and the D25 panel. Four faults, all invisible in the markup: 1. **Rows inside a `Section` lost the form's rhythm** — `.form`'s grid gap falls between its *children*, which on this page are the sections, so every hint ran into the next label. 2. **The retention strips were crushed** — `.seg` is flex and shrank inside `.ret__pick`, wrapping "Bin it once transcribed" onto two lines and "The instance's" into a column one word wide. 3. **"Recordings: 3 sessions are old enough *too*"** read wrong in the audio-only shape, where nothing was named before it. 4. **The D67 banner read as the Server field's error** — full-bleed and flush against the first label. A fifth — a placeholder printed where the instance default goes, when that read fails or is in flight — was caught by a unit test first. ## Checks Frontend **1755 tests in 133 files** (81 new), green. `npm run lint` **0 errors**, 234 warnings — identical to the baseline; neither new file contributes one. `npm run build` clean. `coverage/lane2b.py` prints with **no unclassified rows and no classification changed**. **Nothing depends on the wall clock**: fixture sessions are dated 2019 (older than any window the file sets, and still older in ten years) or carry `confirmed_time: null`. The "shortening that catches nothing" case is a 9,000-day window over a 2019 session — genuinely a shortening, and catches nothing until 2043. A player may not open either section: **absence asserted, not a disabled control.** 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(webapp): Settings — Discord, and Recording, retention & AI (#729, lane 3)
All checks were successful
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 58s
CI / Bot/backend version sync (pull_request) Successful in 59s
CI / Backend lint (ruff) (pull_request) Successful in 1m1s
CI / Docker image build (pull_request) Successful in 1m2s
CI / Bot tests and audit (pull_request) Successful in 1m54s
CI / Feedback site tests (pull_request) Successful in 1m27s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m21s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 5m3s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m25s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 20m4s
3e2be3def4
CAMP-12 and CAMP-13, the fourth and fifth of Settings' nine sections.
`settingsSections.js` flips one word each; the index and the Contents read
that list, so both sections appear without either being edited.

**Discord is its failure states.** Lane 2 drew it once, working. A channel ID
that is not one now says which of the three ways it is wrong; a test that
cannot run says why and keeps its place in the tab order rather than going
dead (D36); a test that fails carries the bot's own sentence, because EX-038
is authored there and it is the only party that knows whether the channel is
missing, forbidden or not a text channel — and a channel test re-reads the
campaign so the SH-8 banner agrees with the answer beside the button. The
webhook is treated as the credential #671 already decided it is: never drawn,
blank on save preserves, and removing one is its own gesture. A campaign with
neither a server nor a webhook says it has gone quiet and that nothing covers
for it (D67, IS-395).

**Recording is where D24's cap meets a GM.** The retention form names the
instance's default and its cap, in all three of IS-312's states. "Keep it" and
"Keep them" appear only where that side's cap is retain_indefinitely, so
nothing is offered that the server would refuse, and an instance that has
never set a cap is unchanged. An over-cap value is refused by the server and
the refusal is shown, naming the cap, rather than clamped away.

Shortening a window now asks first (IS-313, D12, #680): the question counts
the sessions old enough to be caught and names them, leads with the
transcripts whenever they are involved, offers an export first, and uses the
destructive button only then. When nothing is old enough it asks nothing,
saves, and says why nothing went.

Turning per-campaign AI off instance-wide no longer makes the panel vanish
(D25): it says what is stored is kept and will apply again, and says so once
when it does. A provider test names each capability gap rather than counting
them; a side using the instance's models has no Test at all.

Three places the drawing and the server disagreed, each followed deliberately
and noted in the files:

- The campaign payload carries `retention_caps` but not the instance
  *default*, which the drawing names in every state. It is read from
  `GET /public/policies`, where the privacy page already reads it, rather than
  growing the payload in a UI-only PR; the hints name the cap alone if that
  read fails.
- `GET /ai-settings` is 403'd by the same switch D25 is about, so while the
  switch is off nothing can read whether this campaign has overrides at all.
  IS-314's first two states are therefore one panel here, worded to be true of
  both, rather than claiming from a cache what a GM's stored key is.
- No endpoint previews a retention sweep and `SessionListItem` says nothing
  about audio or transcripts, so the question counts sessions **old enough to
  be caught** — exact — and hedges what they still hold, rather than printing
  a number that reads as certain and is not.

Four layout faults came from looking at the built pages rather than the
markup: rows inside a section losing the form's rhythm, the retention strips
squeezed until their labels wrapped a word to a line, the AI panel's heading
flush against its own first paragraph, and the D67 banner reading as the first
field's error.

Coverage: lane2b.py CAMP-12 (CA-054…CA-058, CA-061, IS-395, EX-038) and
CAMP-13 (CA-064…CA-091 bar CA-072, AD-075, IS-311…IS-314); the script still
prints with no unclassified rows and no classification changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merge main into build/lane3-settings-b (#729)
Some checks failed
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 1m59s
CI / Backend lint (ruff) (pull_request) Successful in 1m22s
CI / Bot tests and audit (pull_request) Successful in 2m47s
CI / Bot/backend version sync (pull_request) Successful in 2m4s
CI / Docker image build (pull_request) Successful in 1m53s
CI / Feedback site tests (pull_request) Successful in 2m34s
CI / Frontend tests, audit, and build (pull_request) Failing after 4m53s
CI / Backend migration, tests, and audit (pull_request) Successful in 11m29s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 8m14s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 14m19s
949a9abda2
#774 (the session form and the series) and #770 (J6) landed underneath.
One conflict, in `CHANGELOG.md`; each side held only its own entries, and the
earlier Settings entry this branch corrected ("six sections not yet built" →
four) sat outside the conflict and auto-merged. `App.jsx`, `App.test.jsx` and
`useContents.js` auto-merged.

Full frontend suite on the merged tree: 1864 tests in 136 files, green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test(frontend): the Contents test waits for the request its assertion needs (#729)
All checks were successful
CI / Bot/backend version sync (pull_request) Successful in 36s
CI / Feedback site tests (pull_request) Successful in 48s
CI / Backend lint (ruff) (pull_request) Successful in 1m31s
CI / Summarisation accuracy eval harness (stub provider) (pull_request) Successful in 2m59s
CI / Frontend tests, audit, and build (pull_request) Successful in 4m14s
CI / Docker image build (pull_request) Successful in 3m38s
CI / Bot tests and audit (pull_request) Successful in 5m7s
CI / Backend migration, tests, and audit (pull_request) Successful in 10m25s
CI / Synthetic session harness (no GPU, no LLM) (pull_request) Successful in 12m43s
CI / End-to-end journeys (desktop, phone, keyboard-only) (pull_request) Successful in 8m40s
23c82b494b
CI's frontend job failed on AppFrame.test.jsx: "Unable to find … link …
Settings". Every other Contents entry had rendered; the one missing was the
only one gated on the reader's role.

The test waited for "This week", which comes from the address and is there on
first paint, then asserted Settings, which is GM-only and waits for `my_role`
from the campaign list. So the wait and the assertion depended on different
requests — green on three earlier CI runs, red on a loaded runner. It is the
same shape as J6's axe race (#770): wait for the slowest thing the assertion
needs, not the first thing on the page.

Now it waits for the campaign's own name, which arrives in the same row as the
role. The player-side test beside it already did exactly this, so its absence
assertion was never vacuous. Three runs on a single CPU: 49/49 each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No description provided.