• v4.5.0-rc2 3db8bb8c80

    v4.5.0-rc2
    Some checks failed
    Release / Create Forgejo release (push) Successful in 25s
    CI / Docker image build (push) Successful in 29s
    CI / Bot/backend version sync (push) Successful in 30s
    CI / Backend lint (ruff) (push) Successful in 32s
    CI / Does this change touch the recording path? (push) Successful in 36s
    CI / Feedback site tests (push) Successful in 48s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m1s
    CI / Bot tests and audit (push) Successful in 1m53s
    Release / Build and push versioned images (push) Failing after 3m7s
    CI / Frontend (shard 2/2) (push) Successful in 8m59s
    CI / End-to-end journeys (phone) (push) Successful in 9m35s
    CI / Frontend (shard 1/2) (push) Successful in 10m49s
    CI / End-to-end journeys (desktop) (push) Successful in 12m48s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 15m10s
    CI / End-to-end journeys (keyboard) (push) Successful in 16m45s
    CI / Backend migration, tests, and audit (push) Successful in 23m54s
    Pre-release

    claude-bot released this 2026-10-03 18:17:01 +00:00 | 861 commits to main since this release

    Prerelease of 4.5.0 for testing on the dev server; not for production.

    Added

    • [webapp] The bell reaches older notifications with "Show older". The
      panel showed only the newest twenty; a button at the foot of the list now
      adds the next twenty, moves focus to the first of them, and goes away when
      nothing older is left (#1000).
    • [webapp] Setup's AI step asks for the model's context window. It is
      the same field as Admin → AI models ("How much it can read at once"), saved
      through the same setting, so a server set up through the wizard no longer
      falls back to 32,768 tokens without anyone having been asked (#1001).
    • [webapp] In a campaign that levels by milestones, the GM can hand a level
      to the player (#1007).
      Until now only the GM could level a character
      there. Beside Level up in the sheet's header a GM now has "Let Priya level
      up": it marks the character's next level as waiting, the player is told in
      the bell, and their sheet says "Level 4 is waiting" with Level up and Not
      yet, exactly as it does in an XP campaign when the XP is there. The GM's
      header then reads "Level 4 is waiting for Priya", with Take it back.
      Reaching the level clears the mark, whether the player does it or the GM
      levels the character as before. It is one level at a time, a lock on Level
      still stops the player, and the server refuses a player's level-up in a
      milestone campaign unless the mark is set. Marking a level makes no version
      of the sheet, is not part of a sheet's export, and nothing about it goes to
      email or Discord. An XP campaign is unchanged: its levels wait on the XP.
      The demo seed's Hollow & Ash now levels by milestones, with Tam Ridley's
      level 4 waiting. Migration c1007a1b2c3d adds three nullable columns to
      character_sheets and the level_waiting notification kind.
    • [webapp] A result in the prep sheet's Rules & bestiary and Wiki drawers
      can be read before it is inserted.
      Its title opens the entry in the
      drawer, in the results' place — Table mode's own Find preview: a wiki page
      as the GM reads it, a creature as its block, a rule as its text — with
      Insert still there and "Back to results". Escape goes back too, and focus
      returns to the result it came from. It works the same in the phone's Tools
      sheet (IS-472).
    • [webapp] The campaign search has a page of results, and a rule you find opens
      where you are (#1033).
      The results under the search box now show three of each
      kind — sessions, people, wiki, rules — then "All 14 in Wiki" and "See all 23
      results", which lead to a page listing every match, grouped the same way, with a
      row of links to narrow it to one kind. Pressing Enter in the box goes there too.
      Until now a kind stopped at ten with no way to the rest, and "Rules · 10" could be
      hiding more. The page has no search box of its own: the one in the bar is still
      the only one. A GM gets the same "Look inside transcripts" switch on the page, and
      a player still never sees a GM's material there. Choosing a rule used to do
      nothing; it now opens the rule in place — its text, or a creature's block — and
      closing it puts you back on the result you chose. The wiki's own search box works
      the same way. A kind with more than 200 matches lists the first 200 and says how
      many there are.
    • [repo] The dev demo seed has lane 8's wiki and lane 10's bot tests. Both
      parties and who has been in them, reputation, downtime, custody and an
      unidentified ring; the 27 quests as pages with givers, rewards and
      objectives; mid-text :::gm, :::for and :::party blocks and pages for
      every audience; six handouts with their files and reveals; private page
      pictures; three atlases with maps drawn and tiled at seed time, markers of
      every kind and the party's trail; players' suggestions (the Tuesday Tide is
      now "Players suggest, I decide"); the link index and Recent changes; and six
      finished bot tests with what they spent. Files go under
      media/private/, only after the commit. The map tiling task's cutting is
      now a pure cut_tiles the seed calls directly (#734, #736).
    • [webapp] An article's read view, History, More, Recent changes and Deleted
      articles (lane 8 PR P2, #734; WIKI-3, WIKI-5, WIKI-6, WIKI-17, WIKI-18).
      • An article (/wiki/:entryId) renders the body through WikiText
        (PR P1) with the server's own ?as_user= for a GM's "See it as…"; a page
        outside the reader's audience says only "This page isn't shared with you"
        (W3). Relationships, appearances, mentioned-in, references and a timeline
        follow, with a margin for the picture, facts, a stat block, and (an item
        page) who has had it.
      • History (/wiki/:entryId/history) restores through the server's own
        POST …/versions/{id}/restore, replacing the old article page's plain
        PATCH, which never called it.
      • The ⋯ More menu: adding to a session's shelf, the three LLM drafts,
        Convert the stats, merging a page into this one, and deleting with an
        undo — replacing the last alert()-shaped delete in the product.
      • Recent changes (/wiki/recent) and Deleted articles
        (/wiki/deleted, GM only) round out the wiki's four remaining P1/P2
        surfaces; the front page's "All recent changes" now leads to the former.
      • Lane 7's Read · Talk · History tab bar is kept; History is now a plain
        link to its own page rather than an inline panel.
      • The read half of WikiArticle.jsx is deleted; its edit half stays at
        /wiki/:entryId/edit until PR 3 rebuilds the editor.
      • Frontend only: no backend or bot change.
    • [webapp] A draft, Suggested wiki updates and Wiki health on the new wiki
      (lane 8 page P4, #734; WIKI-9, WIKI-10, WIKI-16).
      • A draft shows the page beside the draft with each stage in plain
        words, typed stats, Send it back and Throw it away. A player's
        suggestion opens here too, compared line by line with the page as she
        reads it
        , never the GM's text.
      • Suggested wiki updates shows where each session's suggestions stand,
        then the pages and relationships waiting, one action at a time.
      • Wiki health lists wanted pages, unlinked mentions, orphans, possible
        duplicates and stale pages, each with its fix.
      • The Contents gains Wiki health for a GM, and Suggested wiki updates shows
        its count.
    • [webapp] The wiki's editor, a new page and Settings → Wiki, rebuilt on lane 8's
      text (WIKI-7, WIKI-8, CAMP-14, D72, #734).
      Write on the left and the page on the
      right (Write | Preview on a phone), with a toolbar, [[ autocomplete, and ◐, which
      opens the audience picker and writes a :::gm, :::party or :::for block for
      whoever was chosen. One Save sends the text, the fields, the facts and the timeline
      together; the text is kept on the device as it is typed, per person. A save that
      started from an older version says who saved since and offers merge by hand, use
      theirs or keep mine. One heading's section can be edited on its own. Pasting a
      picture uploads it to the page's private files; pasting from a document brings its
      headings, bold and lists across as Markdown. A GM sets who sees the whole page
      (D166). Relationships, "Who plays them", the picture, the stat block and links you
      could add moved in from the old edit page. A player's save is only ever her own
      copy of the page, edited:
      the blocks she can't see are never in the editor, and
      the server puts them back where they were. A new page starts from its kind's
      template; where players suggest, it goes to the GM's queue. A name two pages
      share opens a page listing both (D72), and "See it as…" can now pick a character.
      Settings → Wiki sets who may edit, what each kind of page starts with, and the
      reputation levels. WikiArticle.jsx, WikiNewEntry.jsx and WikiSearch.jsx are
      gone.
    • [webapp] History per reader, Recent changes, Wiki health, merging two pages,
      personal notes, and canon in a block
      (lane 8 wave 2 PR 2b, #734; IS-051,
      IS-055 to IS-060, IS-105, IS-147, IS-148, D1, D69, D197, D219). Server only.
      • History (GET /lore/{id}/versions) opens to anyone who may edit the page,
        not only the GM and the primary owner, and drops a version that changed
        nothing this reader could see. Restoring an earlier version widens the same
        way: a character's own player may restore her own page, safely — her restore
        is merged against the live body, never overwriting a block she can't see.
      • Recent changes (GET /wiki/recent) is one feed — made, saved, deleted,
        put back, renamed, merged — filtered so nobody learns that something they
        cannot see has changed. Migration 8c3d4e5f6a7b adds wiki_events.
      • Wiki health (GET /wiki/health, GM-only): wanted links, unlinked
        mentions, possible duplicates, orphans and stale pages.
      • Merging two pages (IS-060): "Merge or Keep both" starts the existing
        LLM-merge draft (POST /lore/{id}/merge); approving it now also moves the
        other page's aliases, links, relationships, references, appearances, owners,
        personal notes and Discussions threads onto the survivor, and trashes it.
      • Personal notes (IS-105, lore_personal_notes): a player's own notes on a
        page, read by the writer and every GM (D197), and never AI context.
      • Canon answers wrap in a block (IS-148, D219) instead of being refused: a
        party or private answer is written as :::party or :::for, naming a player
        as well as a character, pre-filled from the thread's or post's own audience.
        A new page made this way starts at that same audience, so its title doesn't
        leak to someone who can't read the one block on it. Every canon write adds a
        decided in footnote automatically, and a backfill adds one to every post
        already made canon.
      • The NPC tracker folds into the wiki (D1): the migration makes a People
        page for any tracked NPC with no page of that name, carrying its session
        appearances. The old tracker stays working for one more release.
      • The 60 s wait before wiki proposals land is now usually instant: each
        match pass fires consolidation itself once every category is in, rather than
        waiting for the 60 s backstop — which still runs, and now waits longer for a
        slow local model before giving up.
    • [webapp] The wiki's front page and a group, rebuilt (lane 8 PR P1, #734;
      WIKI-1, WIKI-2, D60).
      • The front page (/wiki) leads with a search box scoped to the wiki and
        the rules, then "Recently revealed or updated" (the top of Recent changes,
        D60), "People you've met" (appearances the reader may see, most recent
        session first), and Browse by group. A GM's copy also carries "Waiting for
        you" (the top of Suggested wiki updates) and Wiki health's counts.
      • A group (/wiki/g/:group) lists a group's pages A to Z, narrowed by
        type with a chip when it holds more than one — People, Places, Groups and
        Things are now Contents entries; Events and Lore stay reachable only from
        Browse by group. WikiFront.jsx and its test are deleted.
      • WikiText (src/wiki/): the parser and renderer for the wiki's block
        syntax (:::gm, :::for, :::party, footnotes, [[links]]) that PR P2
        wires into the article page — built here, tested, and not yet on any page.
      • Frontend only: no backend or bot change.
    • [webapp] Handouts, private files, and rules' "Make it ours" (lane 8 wave 2
      PR 5, #734, §3.8, §3.10; W7, W8). Server and the bot's own side; the pages
      follow.
      • A handout is a document wiki page with a file (lore_attachments): PDFs
        are validated by magic bytes with their page count read, images re-encoded
        through the existing picture pipeline. Migration 8a7b8c9d0e1f, on top of
        lane 8's quest pages.
      • Private files stay private (W7). A file narrower than the table no
        longer lives at a guessable, cached-forever address: GET /api/campaigns/{id}/files/{attachment_id} decides on every request whether
        the caller may have it — the page's own audience, or a live reveal's — and
        answers with X-Accel-Redirect to a new internal nginx location that
        refuses a direct request.
      • Reveals carry an audience, a page and an optional session (W8).
        shelf_reveals gains lore_entry_id, piece_label (for a torn letter
        shown in pieces), and lane 7's audience columns; session_id is now
        nullable, so a handout can be shown between sessions. A handout's own
        audience is the union of its live reveals.
      • handout_shown, a bell kind ([wiki] category) and a new, additive bot
        event: the bot fetches the file with GET /api/bot/files/{id} and posts it
        to the campaign's Discord channel, or DMs it to each named person instead of
        posting to the channel. BOT_CONTRACT_VERSION stays 2.
      • "Make it ours" (POST /api/reference/{id}/make-ours): a rules-pack
        entry becomes a GM-only campaign page that keeps a link back
        (source_reference_entry_id), so the rules chapter needs no endpoint of its
        own.
    • [webapp] Handouts and Rules & bestiary, two new chapters of the wiki
      (lane 8 page P6, #734; WIKI-14, WIKI-15).
      • Handouts (/wiki/handouts): a GM's handouts by session with who each
        went to, the ones not shown yet first as GM prep, and a reveal outside
        play as "Between sessions". Show it picks who (the audience picker,
        characters first), posts to Discord unless switched off (D61), says
        when, and names the piece of a letter shown in parts; Take it back
        uses the server's own revoke route. A player reads what was shown to
        her, by session, "Just you" on hers alone, and may add one (just her and
        the GMs) where the campaign lets her write pages.
      • A handout's own page shows its file: an image, a PDF previewed in the
        browser's viewer with "Download the PDF" beside it (download only on a
        phone), or plain text. Every file is read through the authorised files
        route, never a /media/ address.
      • Rules & bestiary (/wiki/rules): search the campaign's loaded rules,
        each hit tagged as rules; a rule opens with its source, a creature as a
        stat block, and a GM can Make it ours. The licence line for each
        loaded pack sits at the foot; with no pack loaded the page says where a
        GM loads one.
      • Server (#916): a live reveal that covers a player opens that
        handout's page to her to read (rendered for her, so GM blocks never
        reach her), and nothing else — not the wiki's lists, search or any
        write; taking it back closes it again. GET /api/campaigns/{cid}/handouts
        lists the handouts shown to the reader, grouped by session. A player's
        own handout page is hers and the GMs'. A page made with "Make it ours"
        says which rules entry it came from.
      • Both chapters join the Contents' wiki group.
    • [webapp] The wiki's links, references and names, one Save, and who may edit
      (lane 8 wave 1 PR 2a, #734, #779; IS-033, IS-036, IS-037, IS-042, IS-046, IS-048,
      IS-049, IS-053, IS-054, IS-085, IS-088, IS-399; D72). Server only; the pages follow.
      • Links are records. Every [[link]] and footnote in a page's text is written
        to lore_links / lore_references on each save (and backfilled by migration
        8b2c3d4e5f6a), each with the block it sits in, read per reader: "Mentioned in"
        never names a page, or a link inside a block, the reader can't see. An article
        now says what each name it links means to its reader (a page, the
        disambiguation page, plain words for a page they can't see, or wanted), and
        lists the footnotes they may open (https, a thread or post, a moment in a
        session, a rule), numbered for them. The server never fetches a URL.
      • Names (D72). A name another page has is a 409 that says what to call the
        two ("Kettlebridge (town)", "Kettlebridge (boat)"); the bare name then opens a
        disambiguation page (GET /lore/by-name/{name}), unless a GM points it at one
        page. A renamed page keeps its old name as another name, and can rewrite the
        links that use it; a renamed character keeps their :::for blocks.
      • One Save. A save sends the version_id it started from; if someone saved
        since, it is a 409 carrying the page as it now is, and nothing is saved. A
        phone can save one heading's section (GET /lore/{id}/sections/{n}).
      • Who may edit (Settings → Wiki): only the GMs, players their own pages (the
        default, and today's behaviour — now including pages they made, which they may
        start), players anything, or players suggest. A suggestion — a new page, an
        edit, a relationship — waits in the GMs' queue and is merged as its writer, so
        nothing they couldn't see is touched.
      • Relationships have an audience of their own and may start and end in a
        session. Templates per kind and the reputation levels (Pathfinder's
        seven to start) are the campaign's to change; exports carry all of it.
    • [webapp] The map, a new chapter of the wiki (lane 8 page P7, #734; WIKI-11).
      • The map (/wiki/map) draws each map over its private tiles, with the
        atlas's levels as crumbs, filters by kind, the party's trail (a stop per
        place, its sessions and days on the road) and "The party is here". A
        marker opens its own map, travels a link, or opens its page; a GM reveals
        a GM-only marker to the players, with an undo.
      • A player adds her own markers, for herself and the GMs, the party, or the
        table (IS-076). A GM adds a map, as its own atlas or opened from a marker,
        and where the party ended a session.
      • Every marker is also listed under the map, and the map pans with the arrow
        keys and zooms with its buttons.
      • The wiki's front page says where the party is (IS-124), and an article
        says where its page is on the map (IS-102, IS-087).
    • [webapp] The party, what it carries, where it stands and what it did between
      sessions
      (lane 8 wave 2 PR 3, #734; IS-107 to IS-122, IS-088, IS-116, IS-206,
      IS-218 to IS-221; D168, D198, D12). Server only; the party page follows.
      • Parties. Every campaign has a party, and may have more (/parties), with
        who has been in each over time: joined and left at a session, with why
        (left, retired, died, captured, swapped), and companions, hirelings, mounts
        and vehicles as pages. Swapping characters keeps the record in step. Migration
        8d4e5f6a7b8c makes each campaign's party and places every character at the
        first session they were played in.
      • "The party" names one party (D168). Threads, posts, pages, relationships,
        facts and blocks can be for a party by name (:::party The Tuesday Watch);
        everything already for "the party" is the first party, so with one party
        nothing changes, and a renamed party keeps its blocks.
      • Items belong to characters (D198): a line is held by a character, the
        party's pool, an NPC or a place, can link its own page, and records who has
        had it. An unidentified item is named as the party knows it everywhere a
        player reads it, until a GM identifies it. Existing lines, XP and gold went
        to the character each player was playing in that session.
      • Removing a line, an award or a movement is undone (D12), not confirmed.
      • Where the party stands with each organisation, changed at a session, on
        the campaign's levels; and downtime, logged by the player and approved by
        the GM, with Pathfinder's activities to pick from.
    • [webapp] The party and the quest log, as wiki pages (lane 8 PR P5, #734;
      WIKI-12, WIKI-13).
      • The party (/wiki/party): each party's home base, fund, XP pool,
        members with pronouns and companions, and active quests; What we carry
        (notable items, then the ledger, filtered by holder; add, give, change);
        XP and gold side by side with each character's total; downtime with
        Pathfinder's activities to pick from and a GM's Approve; Who has been in
        it, current then former, with moving a character to another party and
        why; and Where we stand in the margin. Removing a line is undone, never
        confirmed (D12). A player adds to the ledger only where the campaign
        allows it and changes only her own lines (D6); an unplaced row counts in
        the main party (D236).
      • Quests (/wiki/quests): Rumours waiting for the GM first, then each
        status, with its colour, a failed quest struck through, when it started,
        and "The quest log did not load" rather than an empty list. A new quest is
        a new page, opened in its editor.
      • The Contents gains Quests and The party under The wiki; "Quests & loot"
        goes. /campaigns/:id/manage now redirects to the party page, and the
        old campaign page is deleted with LootLedger, QuestLog and the
        components only it used. An ownership offer is answered with Accept and
        Decline on This week's banner itself.
      • Frontend only: no backend or bot change.
    • [webapp] Quests are wiki pages now (lane 8 wave 2 PR 4, #734, §3.7; D196).
      Server only; the quest page follows.
      • Every existing plot_threads row becomes a page of kind quest, keeping its
        title, status, sessions and reward note: the GM's private notes move into a
        :::gm block at the top of the body, and player_visible becomes the page's
        own audience — table when it was visible, gm when it wasn't. Migrations
        8e5f6a7b8c9d and 8f6a7b8c9d0e.
      • A rumour may be about no quest yet (D196/W10). Confirming one offers
        "Make it a quest," which creates the page and posts it under whoever started
        the rumour.
      • The bot's /quests, the pre-session reminder's open threads, Prep's context,
        the shelf's open loops and the Quests drawer's quick add all read quest pages
        now; GET/POST/PATCH/DELETE .../planning/threads and GET .../quests are
        removed in favour of GET/POST .../lore/quests, filtered per reader like any
        other page. The bot's response shape is unchanged — no contract bump.
    • [webapp] "Test the bot", a GM's way to check the recording pipeline
      before the evening without a real session (lane 10, PR 7a, #736, D124).

      Settings → Recording, retention & AI gains a launch (30 seconds, stoppable
      early, keep-or-delete after), the running stages, the result — per-stage
      timing, the transcript, the summary, and a plain sentence naming who can
      fix a failure — and the kept test runs, with a confirmed delete (D12). The
      session page offers a link to it while Start is refused for being early
      (D149, D222).
    • [webapp] Two public pages for an email's links, the notification grid's
      Discord DM column, and the installed app's update and offline notices
      (lane 10 PR 7b, #736).
      /email/stop and /email/answer work signed out
      (D154, D155): stopping a kind of email needs one click and never reveals
      more than the token names, and answering "Are you in?" records the RSVP
      through the same writer the session page uses. The grid's DM column shows
      "Discord can't reach you" when the bot's last message failed, without
      hiding the switch (HOME-6), and the star-and-wish ask is its own indented
      line under Sessions (D232). The installed app now offers a quiet "Quest
      Board has been updated" notice instead of reloading silently (D13), says
      so while offline, and no longer locks to portrait orientation.
    • [webapp] Email grew a one-click stop, answering "Are you in?" without signing in,
      and a proper recap timing (D154, D155, D162, O7, O8, O15, #736).
      Every outbound
      email now goes through one template, with List-Unsubscribe/List-Unsubscribe-Post
      headers (RFC 8058) and a footer link to /email/stop (POST /api/email/stop, with an
      undo). Session-confirmed and reminder emails carry "I'm in / Maybe / I can't" buttons
      that record an RSVP through /email/answer, good until the session starts. Calendar
      invites carry the session number in SUMMARY and a URL: back to it, no longer ask
      for a reply (RSVP=FALSE — nothing read the old one), and put a cancellation's reason
      at the top of DESCRIPTION. The post-session recap now sends when the GM approves the
      summary, not the moment it is first written, and never twice. A developer-only
      GET /api/dev/email-preview/{kind} (behind ENVIRONMENT=development) renders every
      template from invented fixture data, for reviewing them without real mail.
    • [webapp] The star-and-wish ask is its own row under Sessions in notification
      settings (D232, O29, #736).
      It used to share the "Sessions" cell; existing
      preferences carry over as the new row's starting value (migration 10d4e5f6a7b8).
    • [repo] Journeys J2 and J4b cover lane 10 (#736). J2 asserts what reaches
      the bot — a vote's number and closing time, the confirmation's number, a ❔ from
      Discord showing on the session page, the cancel reason, and a Discord DM with the
      bell's line — and what reaches Mailpit: the confirmation to the player's own
      address, the time in her zone, the unsubscribe headers, answering from the email,
      and Stop → Undo → Stop. The new J4b tests the bot from Settings → Recording and
      checks nothing else happened, and that a recording whose announcement lands
      nowhere still records and says so (D171). The fake bot hands a test's
      generated near-silent WAVs over as the real bot does. J2's bell check no longer
      depends on how many rows earlier runs added (#887).
    • [repo] The dev demo seed has character sheets and stat blocks (lane 11,
      wave A).
      Every sheet is made through the sheet services the routes call,
      from the mini character packs loaded as campaign packs. The Amberwake Accord
      (and its GM copy): Ossian Vale exactly as drawn, Wren's Spells page and focus
      pool, Bastian, Sister Quill, the owner's Hollis (Level & XP locked by the
      GM, "just you and the GM" campaign notes, and a row from the disabled Salt
      Crown pack that keeps its name and numbers), retired Aldous and Hesper's old
      sheet; conditions and hurt HP on two; every old stat block converted, Odile
      Marsh filled out to the drawing, one block partly shown to the party, one
      wholly the GMs', one projected from Hesper's sheet; a rules creature on
      Session 24's shelf with its own hit points; histories of 3 to 6 versions.
      And a new thin 5e campaign, Hollow & Ash, where the owner plays Tam
      Ridley (Fighter 3, HP 31 from rolls of 8 and 7) with four more characters
      and two blocks.
    • [webapp] Sheets reach Admin, the rules, Prep and Members & characters (#737).
      Admin → Content packs lists every sheet definition on the instance — built
      in, from a pack, or made from a pack's older stat blocks — with its version,
      whether it is active or deprecated, how many sheets are on it and how its
      fixtures did; a pack's row names its own definitions and which sheets are
      still on an older version. Loading a pack that carries definitions shows the
      validator's report for each: activated or not, every error (with a "did you
      mean" where there is one), every warning, and which fixtures failed and why.
      One error stops that definition only; the pack's content still goes in, and
      a campaign's own pack says its definitions are not used. An open rules
      result has Add to a sheet ▾, listing the characters and blocks the reader
      may edit that take it — a player's own character, a GM's every sheet — and
      puts the entry on with where it came from; a reader with nothing to edit
      gets no menu. Instant NPC can Save as a page, with a block: a new NPC
      page with a block drafted for the campaign's game, waiting in Suggested wiki
      updates to be checked (no button where the game has no block to draft).
      Members & characters has Open the sheet on a character whose page has
      one, and the members list names each character's sheet_id.
    • [webapp] Level up on the sheet, with XP from the campaign's ledger (#737).
      A character's XP is now every XP award to them, or to their party, since
      their sheet was made. When it reaches the next level the sheet's header says
      "Level 7 is waiting", with Level up and Not yet; a GM can level any character
      at any time. Settings → General chooses whether the campaign levels by XP or
      by milestone; a milestone campaign shows no XP and its GM says when. Level up
      walks the level in the system's own order: hit points (Pathfinder's worked
      out from the level; 5e's rolled on the class die or taken as the fixed value,
      the die kept with the sheet and the roll standing), the class's own features,
      each feat from the loaded content (one that can't be taken yet is shown,
      disabled, with why), a skill increase as far as the level allows, attribute
      boosts, and 5e's improvement (+2 to one score or +1 to two, none past 20) or a
      feat. What changes lists every number that moves, before and after, and why.
      Reach level n keeps the sheet as it was as a version first, so it can be put
      back, and adds "Ossian Vale reached level 7" to the campaign's milestones, on
      the last session played.
    • [repo] J7, the character journey, and the rest of the demo's sheets
      (lane 11, wave B).
      make journeys now walks a character from the first
      step to level 2, on desktop, a phone and the keyboard alone, with an
      accessibility scan at every page: Priya starts a Pathfinder character from
      Members & characters (ancestry, background and class from the loaded
      content, attributes by boosts, nothing rolled), leaves it and carries on,
      and finishes it; Dana shows Odile Marsh's block to the table by field, and
      Priya is handed the shown lines and no others at the table, on the page and
      in the answer behind it; Dana awards XP, the sheet says "Level 2 is
      waiting", and Priya levels up through What changes to Reach level 2, which
      keeps a version first and adds the milestone. The journeys' seed gains a
      campaign for it, with the sample character pack imported through the GM's
      own import. The dev demo seed gains: "Level 7 is waiting" on Ossian Vale's
      sheet, reached through the XP ledger; Tam Ridley's third level taken on the
      sheet, with its hit-point die kept and "Tam Ridley reached level 3" in the
      campaign's history; and two characters begun and left, waiting on Members as
      "Carry on with …" — one with its 4d6 kept, the lowest die of each struck.
    • [webapp] D&D 5e character sheets and stat blocks, built in (SRD 5.2.1).
      The character sheet works out ability modifiers, saving throws, the
      proficiency bonus by level, all eighteen skills with proficiency or
      expertise and Passive Perception, Armor Class from the armor and shield
      worn (medium armor capping Dex at 2), hit points from each level's rolled
      or fixed hit die plus Constitution, Hit Point Dice, death saves, weapon
      attacks and damage, and spellcasting (save DC, spell attack, slots for full
      and half casters, Pact Magic). Exhaustion and the conditions that stop a
      creature moving change the numbers they affect. Creation (standard array,
      27-point buy, 4d6 drop the lowest), levels 1–20 with the Ability Score
      Improvement or a feat at 4, 8, 12, 16 and 19, short and long rests, and
      the stat block in the SRD's order are all defined. Old 5e stat blocks map
      onto the new block. Includes material from the SRD 5.2.1 under CC BY 4.0,
      credited on the sheet. Nothing on screen yet (#737).
    • [webapp] The block on its page, Show the table by field, Make a block, and the block at the table (#737).
      An NPC's or a creature's page with a sheet draws its block in the game's own
      order under the page's lead. The GM sees every line and which the party can
      read — a GM-only line has the GM's rule down its side, with a legend — and
      has Show the table…, Edit the block, Put on the shelf and Export… beside it.
      Show the table is a checklist by the game's groups (name, level and traits;
      perception and senses; languages; skills; AC, saves and HP; strikes by name
      only, or with their attack and damage; abilities…); GM notes are never
      offered. What is ticked becomes the party's for good, on the page, in search
      and on the table, and unticking a group or Take it back hides it again.
      While a session is being played it is also shown at the table at once — the
      party's lines on the players' screens and, if asked, in the campaign's
      Discord channel — and Take it back takes that showing down, as a handout's
      is, telling the other GMs. That showing opens nothing else: a GM-only NPC's
      page stays the GMs'. A GM can make a block from a character's sheet (Make a
      block…): a new NPC page, the GMs' alone, with a copy of the character as it
      is now that later changes to the sheet do not touch. At the table, Find says
      which pages have a block, and a wiki card or the preview draws the block with
      its hit points (−5 / −1 / +) and conditions changed from the card with no
      version made; a monster from the rules keeps its own hit points and
      conditions on its card for the session. Showing a card with a block to the
      table now asks what to show, by field.
    • [webapp] Rules content now carries the numbers a sheet reads. Every
      reference entry has structured fields per kind — an item's Bulk or weight,
      a weapon's damage and traits, armour's AC and Dex cap, a class's hit die, a
      condition's modifiers — filled when a Foundry PF2e or Open5e pack is
      imported, or written directly in a Quest Board pack (checked per kind; ones
      that don't fit are kept as data with a warning). PF2e ancestries and
      heritages, and 5e species, import as their own kinds. The starter-pack
      scripts now build the Player Core's classes, ancestries, heritages,
      backgrounds, feats and equipment (ORC, setting prose stripped) and the
      SRD 5.2.1 only for 5e; publishing them is a release step
      (docs/OPERATIONS.md, "Building the starter packs"). A small mechanics-only
      character pack per system backs the tests, the journeys and the demo
      (#737).
    • [webapp] The engine that will work out character sheets and stat blocks.
      A sheet's definition (its fields, the sums behind them, the bonuses and
      penalties that apply) is now checked and evaluated on the server: every
      number comes with the named terms it was added up from, bonuses of the same
      kind stack the way the game says, conditions and equipped items apply
      themselves, and a definition that refers to a field that does not exist is
      refused with a "did you mean" suggestion. Ships with a generic sheet for any
      game without its own, and a shared set of test cases the browser will be
      held to as well. Nothing on screen yet (#737).
    • [webapp] The sheet at the table: hit points, conditions, spells, rests, and adding from the loaded content (#737).
      A character's player and the GM change the hot half straight from the sheet,
      on a phone too, with no version made: hit points with −5 / −1 / + and their
      temporary points, hero points, dying and wounded as tracks to press, and
      conditions as chips with − and + (Frightened 1), an "until" note (Off-guard
      until the start of your turn) and Add a condition; every figure a condition
      changes shows the penalty in its terms. A caster's spells have Cast, which
      spends the rank's slot (a focus point for a focus spell) and marks a prepared
      spell expended, and the expended mark takes it back; Refocus sits by the focus
      points. Rest… in the header lists the game's own rests and says what each
      gives back before it is taken: Pathfinder's daily preparations (hit points by
      Constitution and level, every spell slot and focus point, fatigued, drained
      and doomed eased) and Refocus, which is off for a character with no focus
      points and says why; 5e's short rest spends the Hit Point Dice the table
      rolled (the page rolls nothing; Constitution is added for each die), and the
      long rest gives back every hit point and every Hit Point Die (SRD 5.2.1) and
      takes off a level of Exhaustion. Hero points come back to 1 when a session
      starts being played, once per session. While a sheet is open it picks up what
      the other side changed within a few seconds. Editing a sheet now offers Add
      from the loaded content — search, the entry's card (damage, traits, category,
      bulk, price, and where it is from with its licence), then how many and where —
      and each row's own quantity, where it is, invested or equipped, prepared, and
      Remove; a row whose pack has gone keeps its name and says where it came from.
    • [webapp] The sheet engine runs in the browser too. The same sums the
      server works out for a character sheet or stat block can now be worked out
      in the page while someone types, so numbers will update as you edit; the
      server's answer still replaces them on every save. The browser's copy is
      held to the very same shared test cases as the server's, down to how it
      rounds, orders text and prints large numbers. It can also show dice the
      server rolled (which dice were kept and which dropped); the browser never
      rolls. Nothing on screen yet (#737).
    • [webapp] Sheets can name their numbers the way the page shows them.
      A line in the working-out under a number can now take its name from the
      sheet ("Dex 4" rather than "Attribute", "Prof 8" with "trained" beside
      it), a bonus or penalty can name itself ("Frightened 1"), and a sheet can
      pick one name out of a list ("Chain mail 16", "d10 → 8, 7"). A sheet
      definition that misspells a function now gets a "did you mean". Also, a
      malformed item row or an impossible rank no longer stops the whole sheet
      from being worked out (#737).
    • [webapp] A content pack can carry sheet definitions (pack format v2). A .qbpack may list sheets/*.json (and sheets/fixtures/*.json) under sheet_definitions in its manifest. An administrator's pack has each definition checked (unknown names come back with a did-you-mean, fixtures are run) and activated only if it passes; a broken one is refused while the pack's content still imports, and a GM's campaign pack keeps its content but has its definitions ignored, with a note. The import response carries the per-definition report, GET /api/admin/sheet-definitions lists every definition, disabling or removing a pack deprecates its definitions (sheets on them stay readable), export writes them back, and a pack's system.json may now update a game system that same pack introduced.
    • [webapp] Pathfinder 2e character sheets and creature blocks, built in.
      The Remaster character sheet is defined in full: attributes with boosts,
      Armor Class with the armor's Dex cap, saves, Perception and class DC by
      proficiency rank, Hit Points from ancestry, class and Constitution, dying,
      wounded and Hero Points, all sixteen skills and Lore with the armor check
      penalty, Strikes worked out from the weapons carried (attack, the second and
      third attack, damage), feats by level, Bulk and encumbrance, invested items,
      a spells page for casters with slots by rank and Focus Points, and each
      condition's real effect (frightened, sickened, clumsy, enfeebled, drained,
      stupefied, fatigued, off-guard, prone, unconscious, encumbered). The
      creature block follows Monster Core's order, takes over today's stat blocks
      field for field, and can be made from a character. Both carry the ORC
      License notice and no setting names. Nothing on screen yet (#737).
    • [webapp] Character sheets and stat blocks are kept on the server. A
      player character's page, an NPC's or a creature's can now carry a sheet,
      made with the game's own definition (or a generic one for any game without,
      and for a campaign with no system). Every value is checked against the
      definition as it is saved; each field can be shown to the table, the party,
      just its player and the GMs, or chosen people, and a character's player
      always reads the whole of their own sheet. A GM can lock part of a
      character's sheet. Autosaves that cross another window's edit are refused
      with the sheet as it now stands, edits keep a history (one entry per person
      per ten minutes of editing) that can be put back, and hit points and
      conditions change at the table without filling that history. Rows added
      from loaded rules keep their numbers if the pack is later taken down. The
      page's stat block is written from the sheet, and every reader gets only the
      lines they may see. A sheet exports as Quest Board JSON. Nothing on screen
      yet (#737).
    • [webapp] Edit a character's or a creature's sheet, and it saves itself (#737).
      Edit turns a sheet's source fields into inputs of the right kind (numbers,
      text, choices that also take a name of your own, ranks as pips you press,
      rows you add and remove on the generic sheet) while what is worked out stays
      text and moves as you type. The header says where the sheet stands (Saving…,
      Saved at, Not saved with Try again) and there is no Save button; what you
      typed is kept on this device until it lands. If somebody else saved first, the
      sheet says so and shows both versions of each field before you keep yours or
      take theirs. The GM can lock a field or a group, and a locked one reads as
      such to the player; each group that has its own audience can be set to who
      sees it. Every save is a version, and any of them can be put back from
      Versions. A person's or a creature's page with no sheet has Make a sheet, for
      the GM and for a character's own player. Also: a page with no sheet no longer
      ends its crumb on a Sheet that is not there, printing leaves the header
      controls off the paper, a private group's pill names the player by their first
      name, and a derivation whose constant is labelled with its own figure prints
      it once.
    • [webapp] A character's or a creature's sheet, to read: Open the sheet from its page (#737).
      The sheet is a document of its own with its pages (Statistics, Abilities &
      inventory, Notes & actions, and Spells for a caster) in the Contents and, on a
      phone, in the Contents sheet. Every figure is shown once with what it is made
      of beneath it, on tap on a phone; ranks, hit points, dying, wounded and hero
      points, strikes worked out from the inventory, feats by level, the inventory
      with its bulk and where each thing is, spells by rank with how many slots are
      left, and the character's own actions with their cost and full text are all
      drawn from the system's definition, so Pathfinder 2e, D&D 5e and the generic
      sheet are one page. An item whose pack has gone says so and keeps its last
      numbers. What the page itself says (appearance, backstory) is shown with Edit
      on the page; a group a player keeps for themselves and the GM wears the GM
      colour. A reader who may not change the sheet is told so quietly. Export
      gives Quest Board JSON, and Print the sheet prints every page. A page that is
      not a person or a creature says it has no sheet. Read only: editing comes
      next.
    • [webapp] The design system gains the character sheet's pieces: Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges and Picker, each with its states on /design (#737, #364).
    • [webapp] Start a character, in the game system's own steps (#737).
      Members & characters has a Start a character for you, and for the GM beside
      each player. A Pathfinder 2e character walks Concept, Ancestry, Background,
      Class, Attributes, Skills, Equipment and Details; a D&D 5e one walks Class,
      Origin, Ability scores, Alignment and Details. Pathfinder attributes are
      boosts by source, with the cap and how many free boosts are left. 5e ability
      scores come from the standard array (drag a number to an ability, or tap both
      to place or swap them), from point buy with its 27 points and the cost of each
      score, or from rolling 4d6 and dropping the lowest, with every die shown, the
      dropped one struck through, and no second roll where the method allows none.
      The dice are rolled by the server and kept with the sheet. Next says why it
      is held back, each step saves itself, and Finish makes the character's page at
      once, with its prose sections named and empty, and the sheet is the player's.
      A character left half-made is kept for 30 days as "Carry on with …" on Members
      & characters (and on its page), and Open the sheet appears beside a character
      that has one.
    • [docs] Lane 10's drawings, for review (#736, #824): the prototype's lane 8 page draws Test the
      bot (D124, D149, D150), the two pages an email opens (D154, D155), Discord DMs on the notification
      grid (D151, D161), the voice channel and "Where" (D159), and a recording announcement nobody could
      post (D171), and brings its older stills into line with D96, D97, D123 and D151–D163. The owner's
      answers to its questions are recorded as D221–D232, and Settings → Discord is redrawn with pickers
      for the server, channel and voice channel (D226). Catalogue rows IS-443 to IS-458;
      coverage/lane8.py and the CAMP-12, CAMP-13, SESS-2, TABLE-4 and HOME-6 scripts classify them.
    • [bot] The bot says it can run Test the bot (D124, #824, #864). GET /identity's
      features lists record_test beside dm, so Quest Board can tell a bot too old for
      Test the bot before a GM presses the button (additive; bot contract unchanged).
    • [webapp] Admin's recordings table says when each recording goes. "Kept 90
      days, as the campaign asks: until 23 November", or whose setting it is when
      that is the instance's or its cap. The date is the one the nightly clean-up
      acts on, taken from the same function; GET /api/admin/recordings gains
      audio_expires_at, audio_retention_mode, audio_retention_days,
      audio_retention_source and content_approved (#872).
    • [webapp][bot] Taking a handout back deletes its Discord post or DMs (D157,
      D238, #896). The handout path never recorded where it landed, so its own
      Undo neither told anyone nor deleted anything — this closes both gaps.
      • A handout reveal now records where it went: the campaign's channel post
        reuses lane 10's shelf_reveals.discord_message_id/discord_channel_id
        (POST /api/bot/reveals/{id}/message), and a chosen audience's DMs each
        get a row in a new shelf_reveal_messages table via a new, additive
        POST /api/bot/reveals/{id}/dm-message. Migration 8c9d0e1f2a3b, on top
        of lane 8's map. The reveal is now committed before the bot is asked to
        post it, so the bot's report of where it posted can never beat the row.
      • A handout's Undo and a shelf card's take-back are now one code path
        (shelf_service.take_back): stamp it, tell the campaign's other GMs
        (D238), commit, then publish reveal_taken_back carrying every recorded
        post or DM as extra.messages, so the bot deletes each — best-effort,
        silent on a message already gone. Lane 10's single-post fields stay in the
        event for an older bot. BOT_CONTRACT_VERSION stays 2.
      • Handout embeds are ported to faces.handout, the bot's one post shape
        (EXT-2), with a golden row in test_faces.py and a dev-preview specimen.
    • [webapp] A quest can be changed (D242, #925). A quest's page shows its status, main or side, who gave it, the reward, the sessions it started and ended in, and its objectives; a GM edits all of these, with an objective kept for the GMs never shown to players. Changing the status puts a line on the quest's timeline. A GM confirms a rumour as a quest, or marks it not canon, from the quest log.
    • [repo] A shared Claude Code setup for tiered delegation (#935). Specialist
      agents in .claude/agents/: qb-implementer and qb-frontend (Sonnet,
      medium effort), qb-preflight and qb-render-check (Sonnet, low), and
      qb-test-runner and qb-ci-triage (Haiku, low). Skills: /qb-test,
      /qb-ci-log, /qb-render, /qb-preflight and /qb-deploy-dev. Workflows:
      /qb-verify, /qb-build-slice and /qb-review. The local CI recipes now
      build test images from the Dockerfile's backend-dev and bot-dev targets,
      so they can no longer go stale.
    • [repo] The dev demo seed writes a GM copy of The Amberwake Accord. A fifth
      campaign, "The Amberwake Accord (GM copy)", mirrors the original row for row
      under its own ids, with the seats swapped: the owner's account owns and runs
      it and is credited with everything Dana did as GM, and Dana plays the
      owner's character. Every GM-only surface can now be reviewed on Amberwake's
      data. Its GM's bell also has a session short of players and a recording
      about to be deleted. The same builder writes both (engine.Variant), and
      --reset rebuilds the copy with the rest (#951).
    • [webapp] A Test-the-bot run's AI usage now counts in the campaign's usage and credits like any other run, marked as a test (D227). session_usage.session_id is nullable; a test row carries recording_test_id and purpose="test" instead, and Admin → AI usage & speed shows it as "Test the bot".
    • [webapp] Settings → Discord chooses its server, channel and voice channel from
      pickers, once Discord is linked (D226, D159).
      The bot answers two new internal
      routes — the Discord servers it shares with a given user, and the text/voice
      channels it can see in one of them — and the backend asks them only for the
      signed-in GM's own verified Discord link, never a Discord id from the client. A
      GM with no verified link, whose bot can't be reached, or who just prefers to, keeps
      typing IDs, exactly as before.
    • [bot] GET /discord/guilds and GET /discord/channels, bot-auth and additive
      (BOT_CONTRACT_VERSION stays 2): list a user's shared servers, and the channels the
      bot can see in one of them. Both re-check that the given user is actually a member
      of the guild in question before answering.

    Changed

    • [webapp] A wiki page in the bin now travels in a campaign's export, and
      an import puts it straight into the bin (#1009).
      Since #990 a binned page
      was left out of the file, so moving a campaign ended the days a GM has to
      restore it (30 unless the instance says otherwise). It is now carried in
      data.json (a new wiki_bin section; export schema 16) with the time it
      was binned, and an import writes it already binned: for the GMs only until
      one restores it, never live for a moment, and with the days it had left
      rather than a fresh count. What stood
      beside it comes back as it stood (its aliases, owners, relationships, name
      targets, picture, shelf cards and prep items, the thread about it), so
      Restore brings back what it would have in the old campaign. What was shown
      at the table stays shown until a GM takes it back, as it does where it came
      from: a card shown from the page, and the page's own reveal when it was
      shown in a session, are still listed under that session for the people they
      were shown to, by the title they were shown with; a player's handouts list
      leaves the page out until it is restored. A binned page is in nothing a person reads:
      no file under wiki/, no count in README.md, its picture under
      media/bin/lore/. A page already past the bin's days when the file is
      imported is deleted by the next daily purge
      , as it would have been where
      it came from, so restore it as soon as you have imported. Every other bin still
      stays behind, and an archive from before this release imports as it did. A
      Quest Board older than this one refuses a schema 16 file by its version;
      this one now says "made by a newer Quest Board … update, then import it
      again" when it meets a file newer than it reads. A fact or a timeline row
      that names a page further down the file now keeps its link on import, where
      it used to lose it. See docs/API.md.
    • [webapp] Shortening how long a campaign keeps transcripts is an ordinary
      confirmation now (#1010).
      It was the serious kind, with a red button, from
      when the older transcripts were destroyed that night. Since #680 they go to a
      bin and can be restored, so the question keeps its words and its offer to
      export the campaign first, and loses the red.
    • [webapp] The footer is at the bottom of the page, not a strip pinned under it.
      The version and the project, Help, Accessibility, Privacy and Licences
      links used to sit under the page's scroll area at every width, taking two
      lines of a phone's screen for good and cutting off the end of long lists.
      They are now the last thing in the page itself, at the window's bottom on a
      short page and reached by scrolling on a long one, beside the Contents
      rather than under it (#1020).
    • [webapp] The party page shows XP and gold as their figures, with a small
      History beside each.
      History opens every entry, newest first, in a dialog
      ("XP history" / "Gold history"), with the same Remove and Undo; the page no
      longer lists them all (#1020).
    • [webapp] The "Quest Board" wordmark in the app bar is now a link to Home.
      Inside a campaign it was only a picture of the name; it now takes you back
      out to Home from every page of the signed-in app, and is named "Quest Board,
      home" for a screen reader (#1020).
    • [repo] The Claude Code setup is now a thin layer over a shared, user-level set (#1037).
      Quest Board's test recipes, preflight checks, release rules, deploy facts and UI
      settings moved into a ## Project profile section of CLAUDE.md. /qb-test,
      /qb-ci-log, /qb-preflight, /qb-deploy-dev and /qb-render keep their names
      but delegate to the global skills and agents, and /qb-verify, /qb-review and
      /qb-build-slice delegate to the global workflows. The qb-test-runner,
      qb-ci-triage, qb-preflight and qb-render-check agents are gone, replaced by
      the global test-runner, ci-triage, preflight and render-check. The pull
      request template now asks for a changelog.d/ fragment instead of an edit to
      CHANGELOG.md.
    • [repo] Prereleases are cut for the dev server, and the Quest Board hosts
      run the published images.
      A vX.Y.Z-rcN tag now publishes release notes
      rendered from the pending changelog fragments (changelog.py render, which
      consumes nothing), and its images are :X.Y.Z-rcN only. A committed
      docker-compose.images.yml pins the six app services to the release's images
      and removes their build:; its tags must equal APP_VERSION, which
      check_version_sync.py now enforces. A host that adopts it sets
      COMPOSE_FILE=docker-compose.yml:docker-compose.images.yml in its .env and
      updates by checking out a release tag. A self-hoster who builds from source
      sets nothing and is unaffected. docs/OPERATIONS.md covers cutting a
      prerelease, the image update, rollback and production updates (#1043).
    • [webapp] Cutting a recording into speech spans no longer starts ffmpeg
      once per span (#684).
      With silence trimming on — and always on a managed
      provider with a per-request size limit — every speaker's track is cut into
      its speech spans before transcription, and each span was its own ffmpeg
      process: around 1,300 of them for a three-hour, five-speaker session, most
      of whose time was process start-up. The spans of a track are now cut
      twenty-five to a process. The files are byte for byte what they were, so
      transcripts, timestamps and the counts on a summarisation run are unchanged;
      on the 30-minute test scene the cutting went from 208 processes and about 17
      seconds to 11 and about 2. If a batch fails, its spans are cut one at a time
      exactly as before and the worker log says so once
      (… in one ffmpeg run failed … cutting them one at a time instead). ffmpeg
      briefly uses a little more memory for a batch than for a single cut —
      roughly 65 to 80 MB against 56 MB; the batch size was chosen to keep that
      small in a worker limited to 1 GB. Nothing to configure.
    • [repo] The synthetic-session guard tests take about half as long (#684).
      Besides the batched cuts above, the guards no longer re-run silencedetect
      over a track ffmpeg has already scanned unchanged (test-only; a rewritten or
      copied track is always scanned again), and a new guard compares the batched
      cuts of the scene's tracks with single cuts, byte for byte, in the CI job
      that is not allowed to skip.
    • [webapp] Anyone who may edit a wiki page directly can upload or remove its
      picture
      (D234, #734). It follows the campaign's Who may edit: a player's own
      pages by default, any page they can see under "players anything", none under
      "suggest" (D233). Setting a picture from a web address stays the GMs', since it
      makes the server fetch something.
    • [webapp] Admin's Recording & retention, Backup, Import and Audit log are
      rebuilt on their own pages (lane 9 page 4b, #735).
      Recording & retention
      gains the default/cap split (D24) with IS-311's confirm before a tighter
      cap catches a campaign's sessions, and says D179's retention promises
      correctly. Backup queues without asking first (D12), polls while a run is
      in progress, and no longer claims uploaded images are left out, since
      D143 already put them in. Import a campaign now shares its form with
      Home's New campaign → Import a file. The audit log filters by name
      rather than id, groups "What happened" into categories, and reads its
      detail as labelled lines with the record as stored one click further.
    • [webapp] Admin's AI models, AI usage & speed, Discord, Email and
      Instance health are rebuilt on their own pages (lane 9 page 4c, #735).

      The old 49-field Bot Settings tab splits into AI models (the capability
      panel, the transcriber and summariser, the summary and wiki modes, and
      who is using their own) and Discord (the bot's address and its key,
      with IS-394's identity check replacing the token field per D139).
      AI usage & speed gets an address of its own for the hardware preflight
      and the usage report. Email's test now sends what is typed, saved or
      not, and answers with the SMTP server's own reply instead of queuing.
      Instance health has a screen for the first time: the AI queue's
      free/busy/stuck verdict, polled every 10 seconds, and which secrets
      this server can read. Also fixes a date column (Joined, Last here,
      Started, When) wrapping mid-date on Users, Campaigns and Audit at 390px.
    • [webapp] Admin's Content packs, Content notices and Plans & entitlements
      are rebuilt, and the legacy Admin page is gone (lane 9 page 4d, #735).

      Content packs takes lane 3's kit pieces rather than the old Tailwind
      component, and adds starter packs and an instance pack's own default
      ("Every member", AD-120). Content notices gets a screen for the first
      time: where a rightsholder writes, filing and filtering notices, and
      acting on one (turning a pack off, or removing it or just the records
      named, all or nothing). Plans & entitlements shows the four prices
      Plan & billing already reads, hosted servers only. pages/Admin.jsx
      and its last two orphaned panels (AdminAiUsage, AdminHardwareProfile)
      are deleted. Also fixes two dialogs (making an administrator, handing
      the server or a campaign on) that drew no buttons at all, and a narrow
      screen squeezing a table's columns instead of letting it scroll.
    • [bot] Commands, and a private way to raise a concern (lane 10, EXT-1, #736).
      /recap now also takes a session's number (/recap 13), resolved to the right
      campaign in a server that binds more than one (O9); /next, /quests, /history
      and /ask resolve the same way, and every one of them offers a campaign: picker
      when it needs to ask. /history and /ask now answer privately, /history lists
      numbers instead of session IDs, and /ask names the sessions its answer was drawn
      from — worked out by Quest Board, never the model (O16). /next shows RSVP counts.
      /note and /help's words follow D97/D106. A new /concern opens a private
      Discord form, in a server or a DM, that reaches only the campaign's GMs through the
      same anonymous queue as the web's Table rules & safety flag (O13, D160) — nothing it
      carries is ever echoed into a channel, logged, or stored with a sender. All
      additive: BOT_CONTRACT_VERSION stays at 2.
    • [webapp] The server side of the above (#736). GET /api/bot/guilds/{id}/sessions/by-number/{n} resolves a session by number within a
      guild's campaign; channel_id/campaign_id are new optional parameters on
      /next-session, /quests, /sessions/list and /sessions/ask for the same
      resolution, returning 409 with the campaign names when a server holds more than one
      and neither narrows it. /sessions/ask's response gains sources. A new POST /api/bot/safety/submissions is /concern's door into the existing, sender-less
      safety queue, kept out of the access log the same way the web route is. No
      migration.
    • [bot] Every Discord post has one shape (lane 10, EXT-2, #736). A coloured edge
      for its kind (six tones that read on Discord's light and dark themes), a title in words
      with the kind first and the session by number ("On: Session 14", "Cancelled: Session
      14", "Recording saved · Session 14"), the facts as fields — When, Where (the
      campaign's voice channel, D159) and Who's in — one link back to Quest Board and
      none on a server with no public address, and Discord timestamps throughout. All built
      in one place, questboard_bot/faces.py. A time added to an open vote is now also
      posted on its own ("A time was added · Session 14"); a cancellation says who and why
      (D101); the approved summary is the only summary post and says who approved it (D96);
      no post promises a summary "when it is ready" any more, and a session_summarised
      event still queued from before D96 is dropped rather than posted. The "Recording
      stopped" line is gone: the table hears once, after the handoff. The bot's status reads
      "Recording · /help" while any recording runs (D158). tests/test_faces.py pins every
      face, and python -m questboard_bot.preview --channel <id> --yes posts every one,
      from invented examples, into a channel of the developer's own test server (D164, O17).
    • [webapp] Bot events carry what the posts need (lane 10, #736). Every session event
      adds number and voice_channel_id; the poll adds closes_at, the reminder rsvp,
      the cancellation cancelled_by, the played post duration_s, the approval
      approved_by, and a slate change says whether a time was added and by whom. The
      bot's /api/bot/sessions/{id}/timeslots adds number. All additive; the bot contract
      stays at 2.
    • [bot] Reactions and taking things back (lane 10, EXT-3, #736). Reacting to a
      confirmed session gains ❔ ("I might make it", D50) alongside ✅ and ❌, with mutual
      exclusion between the three enforced on the message (Manage Messages permitting); the
      confirmed post's footer says so. Voting on a proposed session now names the voter's
      whole slate in the acknowledgement ("A and C work for you"), not only the option that
      just changed. Taking a revealed card back down in Quest Board (O10) now deletes the
      bot's own Discord post too, and tells the campaign's other GMs (reveal_taken_back, a
      new bell kind). All additive: BOT_CONTRACT_VERSION stays at 2.
    • [webapp] The reveal message, and its removal (#736). POST /api/bot/reveals/{id}/message records where a shelf_reveal embed landed
      (shelf_reveals.discord_message_id/discord_channel_id, migration
      10b2c3d4e5f6); revoke_reveal now publishes reveal_taken_back for the bot to
      delete it, and notifies the campaign's GMs other than the one who took it back.
      BotAttendanceRequest.attended is now optional alongside a new rsvp field
      (yes/maybe/no); the vote endpoint's reply carries the voter's your_answers.
    • [webapp] The D&D 5e sheet names what it adds up, as drawn. Each number's
      working now reads "Chain mail 16 + Shield 2", "10 + 2 × (d10 → 8, 7) +
      Con 2 × 3", "Str 3 + Prof 2" and "2 + ⌊(3 − 1) / 4⌋" instead of generic
      labels, and penalties say their level ("Exhaustion 2") or their cause
      ("Chain mail, needs Str 13") (#737).
    • [webapp] Existing stat blocks become blocks on the new sheets, automatically.
      On upgrade, every person's and creature's stat block turns into a creature
      sheet for its game — Pathfinder 2e, D&D 5e, a game added by a content pack,
      or the generic stat block when its game is gone — keeping who could see it:
      a block everyone could read stays readable by everyone at the table, and a
      GM-only or player-private one stays with the GMs. Nothing is lost: the old
      block is kept with the sheet, a value that doesn't fit the new sheet is kept
      aside rather than dropped, and the page's block is rebuilt the first time
      it's opened. The stat block editor on a page, the importer, "Stat block" and
      "Convert stats" all work on the sheet now; a drafted stat block fills the
      new block's fields and, once approved, lands on the page's sheet instead of
      in its text. Administrators can run python -m app.sheets.rebuild_projections
      (with --dry-run to see what it would do); the worker also does it once
      when it starts (#737).
    • [docs] The lane 9 drawing gains Actions and Rest… for the sheet build (#737).
      Notes & actions lists a character's actions with their cost and full text, and
      the sheet header gains Rest… (D209); the drawing also drops its explanatory
      lines, the recap promise and the Foundry export (D208, D210, D200). The
      catalogue gains IS-459 to IS-462 and the survey is committed as
      docs/design/lane11-build-contract.md.
    • [webapp] The old stat block editor is gone; stat blocks are edited on the sheet.
      On an NPC's or a creature's page, the editor now shows "Open the sheet" when
      the page has one and "Make a sheet" when it does not, in place of the form of
      fields. Saving a page with a stats value is refused with "Stats are edited
      on the sheet." Pages still show their stat block, and importing a campaign
      still brings stat blocks in (#737).
    • [webapp] Pathfinder 2e sheets read the way the game prints them. The
      sums under a number now name the attribute they use ("Dex 4", "Wis 4") for
      class DC, spell DC and attack, and Strikes; Hit Points read "Ancestry 8 +
      6 levels 60 (Class 8 + Con 2 each)"; and a condition that has a value says
      it ("Frightened 1", "Stupefied 2", "Drained 1"). The numbers themselves are
      unchanged (#737).
    • [webapp] Admin follow-ups and the demo seed's own surfaces (lane 9 PR 6, #879).
      GET /admin/settings/bot/identity passes through the bot's own reason for
      a disabled Discord connection, so the Discord section says "Discord
      refused the token" separately from "No Discord token set" instead of one
      generic message. Instance health's "Campaigns' own keys (N)" line is real:
      the secrets audit now counts every campaign's own transcriber/summariser
      key and how many this host's SECRET_KEY can no longer read. AdminAi
      and AdminRecording regain the test coverage the legacy Admin page's
      deletion (#735) didn't carry over — gap severity ordering, a provider
      guessed from the address, the retention form's defaults and caps together,
      and the wiki pipeline's mode change and error states.
    • [repo] The demo seed gains two thin campaigns purely for Admin's own
      pages (#879, D145).
      The Salt Crown (archived) and Brinewatch (a
      one-shot, binned), both owned and GM'd by an invented person who has not
      signed in since — a stale owner, a failed recording, a transcript kept
      "for good" for the retention cap-confirm to name, a second campaign pack
      disabled after a content notice, and nine more audit-log events. Nobody
      plays these two tables; see webapp/backend/demo/README.md for why they
      stay that thin.
    • [webapp] The email answer page now says when a session has started or been cancelled, and links to it; the Notifications page says which of Discord's two refusals stopped your DMs.
      Answering a started or cancelled session from an email is refused (409) instead of reading as an expired link.
    • [webapp] Awarding XP is the GMs' alone (D241). The campaign's "players can add loot" setting still lets players add items and gold, but no longer XP; removing or restoring an award is the GMs' too.
    • [repo] The backend test suite is much faster. The local-Whisper /health probe is stubbed by default (tests that exercise it opt in with @pytest.mark.real_asr_probe), removing the ~8 s DNS-plus-timeout wait from about thirty recording-pipeline tests; a test network guard now fails any test that resolves an unmocked hostname (opt out with @pytest.mark.allow_network); and the synthetic-session harness (@pytest.mark.synthetic) is deselected from the backend CI job's pytest -m "not synthetic" since its own job already runs it. Full run: ~1,061 s before, ~536 s after (about 9 min).
    • [repo] CI's two slowest jobs now run in parallel pieces (#939).
      • Vitest was running on one worker. CI jobs get 2 CPUs, and vitest's default is CPUs − 1, so the whole suite took 1,603 s. The frontend job is now two shards, each with --maxWorkers=2. Lint, the build and the audit run once, in shard 1.
      • The journeys were running one project after another. They're now a matrix of three jobs (desktop, phone, keyboard), each with its own stack. The one-worker constraint (per-IP login rate limit, one shared seeded database) is per stack, so no spec changes.
      • Three prep-sheet test waits no longer fail under load. Their explicit timeout: 4000 was shorter than the suite-wide 5 s asyncUtilTimeout. The two that wait on the 1 s autosave now wait 8 s.
    • [webapp] Session emails no longer repeat "Quest Board" as a heading under the brand line; they open with the news itself, as drawn (EX-040).
    • [webapp] The design system reference page, /design, now ships in production
      builds, for server administrators only.
      Anyone else, signed in or not, gets
      the same "not found" page as any unknown address. Development builds still
      open it to everyone.
    • [webapp] Cards on the GM's table board are packed, and a new one lands beside
      the last card on screen.
      A card nobody has moved used to take a slot by its
      place in the whole shelf, so a shelf whose cards had mostly been placed by hand
      drew a new one far down the board with empty slots above it. Unplaced cards now
      fill the first free spots around the ones the GM placed (which never move), and
      a note or a card added from Find goes next to the last card in view — to its
      right, else under it — never on top of another card.
    • [repo] Changelog entries are now fragments in changelog.d/, gathered
      into CHANGELOG.md at release.
      Every PR used to add its bullet
      directly at the top of [Unreleased]'s section lists, which clashed
      between PRs merged close together. Now each PR adds a small file under
      changelog.d/ instead; make changelog (scripts/changelog.py assemble)
      gathers them at release time, and make changelog-check (also run by CI)
      validates them. See changelog.d/README.md.

    Fixed

    • [webapp] Level up no longer offers another class's feat as if it could be taken. At a class feat step a Fighter was listed a rogue's feat, because feats carry their class as a trait rather than a prerequisite. It is shown disabled with the reason ("A rogue feat."), the way a feat with an unmet prerequisite is, and applying the level with it is refused. A feat can still be typed by name (D261, #1008).
    • [webapp] A temporary hit point figure typed straight after a sheet opens is no longer put back to the old number. The box reset itself to the server's figure on mount, which could run after an early edit (#1017).
    • [webapp] An article's text no longer sits against the rule under its
      tabs.
      The Read tab now leaves the same gap below the rule as the other
      pages, for a player's view and a GM's, at every width (#1020).
    • [webapp] On /design, pressing anything no longer blanks the open panels.
      The bell, menus, account menu and search drawn open closed on a press
      anywhere else on the page, or on Escape, and nothing brought them back; the
      ones drawn open now stay open (#1020).
    • [webapp] A page shown to you as a handout no longer logs a 404 for its map
      markers.
      "On the map" asked about a page the reader may open through a
      reveal, and the route only knew the page's own audience; it now answers with
      the markers she can see, usually none (#1020).
    • [webapp] Opening a notification from the bell now marks it read. Following a
      row's link (a click, Enter, or a new tab) did not clear it in a browser,
      because the frame closed the panel before the row heard the click; the row
      now marks itself first. The tick beside a row still marks it without opening
      it (#1020).
    • [webapp] The page a player meets on a GM's prep address reads as
      English.
      It said prep "is where each session is got ready"; it now says
      "Prep is for the GMs of this campaign: it is where a GM gets each session
      ready." Planning's own version of the line is reworded the same way
      (#1020).
    • [webapp] A scene can be deleted from the prep sheet, and a card's "File it
      in" menu there can be read and used.
      The sheet's Shelf section could make
      and rename a scene but not delete one; each scene now has Delete beside
      Rename, the same delete Table mode's Scenes panel has: at once, with an Undo,
      the scene's cards staying on the shelf under "No scene yet" until the undo
      files them back. The menu on a shelf card was drawn inside its list and cut
      off at the list's edge, so only its heading and "No scene" could be seen; it
      now hangs clear of the list, and it offers every scene with the card's own
      marked, where it used to leave that one out (#1020).
    • [webapp] A search puts the thing you named first. Searching the wiki for
      "Odile" listed Bastian Crowe, whose page mentions her, above Odile Marsh:
      a wiki search had no ranking at all and answered in the wiki's browsing
      order (kind, then title), where a character comes before an NPC. Every
      search now follows one rule, written once on the server
      (app/services/search_rank.py): the name that is the query, then a name
      that starts with it, then a name with a word that starts with it, then the
      same for its aliases, then everything that only mentions it — each of those
      in the order the search had before. Case, accents and extra spaces don't
      count. It applies to the app bar's search, the wiki's own, Table mode's
      Find, the prep sheet's Rules & bestiary and Wiki drawers and its article
      pickers, and the rules search, and it is applied before any limit, so the
      exact match is never the row a cap drops. A player's results are ranked by
      the names a player knows: an unidentified item by what the party calls it,
      and a page they can't see is not there to be ranked. A rules record is also
      now found by the start of any word in its name ("stalk" finds Reed Stalker).
    • [webapp] The wiki front page's results open under its search box. They
      hung from the far right of the page's main column, away from what was
      typed: the box is the app bar's search component, whose results hang from
      the right edge of a box that, on this page, was as wide as the column. The
      box is now up to 32rem wide and the results start at its left edge at the
      same width, on a phone too.
    • [webapp] The last results in a prep-sheet drawer are no longer cut off by
      the footer.
      The tool margin beside the sheet was sized from the window
      (100dvh - 6rem), but it sticks inside the page's scroller, which is the
      window less the app bar, any banner and the footer — so its last 36px or
      more sat behind the footer, where neither its own scroll nor the page's
      could reach. It is now sized from the scroller itself, measured, so it holds
      whatever the bar and the footer do.
    • [webapp] A result found by its own name no longer says the name twice.
      "Odile Marsh" sat over "npc · Odile Marsh". The line under it now says how
      the page starts — in the text that reader may read — or, for a session, how
      its summary starts; with nothing to say it is just the kind or the date.
    • [webapp] Table mode's Find no longer scrolls sideways. A row was as wide
      as its whole snippet (778px in a 590px list), so the list scrolled across
      and the snippet was cut at the list's edge. Rows now fit the list, the title
      keeps its room and the snippet ends in an ellipsis.
    • [webapp] In the prep sheet's lookup drawers, "In the sheet" stays on one
      line on a phone, and a preview that opens is brought into view
      so its
      Insert is not below the fold of the Tools sheet.
    • [webapp] A GM can find the sweep, and the table, from a session that has
      been played.
      "Sweep the shelf" was only at the foot of the Review tab's
      checklist, and nothing on a played session's page led back to its table. A
      GM now has both under the tabs on Read as well as on Review: Sweep the
      shelf
      while any card from the session is still on its shelf, and Open
      the table
      always — the evening's board, its scenes and what was shown are
      still there. In Table mode the sweep's button is called Sweep the shelf…
      too, in the bar and at the foot of the phone view, where it used to say
      "End of session…". Closing a sweep opened from the session page takes
      ?sweep=1 off the address, so reloading or going Back does not open it
      again (#1020).
    • [webapp] Showing something to the table from a GM's phone no longer turns
      large.
      A card put on stage on a phone was set in the desktop stage's
      display type — everything a size up, its first line at heading size — when
      only the title had been brought down for a phone; the phone's stage is now at
      the phone's size, as drawn. "Show the table" opened a dialog whose field was
      13px, which makes iOS Safari zoom the whole page in as the field takes focus;
      on a phone the field is 16px. A long session or card name wraps instead of
      pushing the page sideways, the bar's clock is the phone's size, and every
      button on the phone view and in its dialogs is at least 44px, including
      Leave, the stage's ×, ‹ and ›, and the sweep's rows (#1020).
    • [webapp] A thread marks the posts that were unread when you opened it.
      Each carries a dot and the word "New" (not colour alone), with a "New since
      you were last here" line above the first; they stay marked for the visit
      and are read on the next. The thread's read answer gains is_new on each
      post and read_through on the thread, both additive (#1020).
    • [webapp] The wiki's front page lists Quests under Browse by group.
      Every entry type had a row there but a quest's, which was only reachable
      from the Contents' quest log; the row opens it and counts the quest pages
      the reader can see (#1020).
    • [webapp] The Contents' wiki chapters now include Events and Lore.
      They were reachable only from the front page's Browse by group, so the
      sidebar and the phone's Contents looked as if categories were missing; both
      now list all six groups in Browse by group's order (#1020).
    • [webapp] Back and forward return to where you were on pages that load their content after the page opens. A session's Review tab and Admin → Audit log came back short, or at the top, because the position was set before the page was tall enough; it is now kept trying until the page can reach it, and stops the moment you scroll yourself (#1023).
    • [webapp] The notifications panel no longer scrolls inside itself. Its hidden "unread" labels were not contained by the list, which made the panel taller than its box; the heading could end up half cut off at the top (#1023).
    • [webapp] The wiki editor's Save row stays at the bottom of the window all the way down the page. It used to leave the screen once the reader scrolled past it to the stat block, picture and relationships; it is now the last thing in the editor and settles at the end of the page, above the footer (#1023).
    • [webapp] The page no longer lets the whole window scroll under the app frame, and the bell and Contents buttons are 44 pixels wide on a phone. A visually hidden label far down a long page could make the window itself scrollable; the page's own scroller now contains it. The footer's links no longer leave a dangling "·" at the end of a wrapped line (#1023).
    • [webapp] A menu no longer hangs off the side of a phone screen. A menu
      hangs from one edge of the button that opens it, which is right only while
      that button sits near that edge of the page. A wiki article's "See it as…"
      wraps to the left on a phone, and its menu ran 30px off the screen with its
      names cut short. Every menu now checks where it has opened and moves back
      inside the page, and is never wider than the screen (#1029).
    • [webapp] Choosing a feat or a class from the loaded content no longer
      loses the list while it searches.
      Level up's feat picker and Start a
      character's "Choose from the loaded content" emptied their list for the
      length of every search, said "Nothing matches" until the answer came, and
      took a row out from under whoever had just reached it with the keyboard. The
      results on screen now stay until the new ones arrive (#1030).
    • [webapp] Coming back to a discussion just as it finishes loading now
      refreshes it.
      A thread, a list of threads and a Talk tab's count read
      again when the window regains focus. A focus arriving in the instant the
      page finished loading was dropped, because the page had not yet been told it
      was loaded; the next one worked. It also made one test fail now and then on a
      busy build machine (#1036).
    • [webapp] A flaky Preview test waits for the scroll it asserts (#1039).
      The test for bringing the preview panel into view a second time checked the
      call on the line after the entry's text appeared, which on a busy CI runner
      is before the effect that makes the call has run. Test only.
    • [webapp] Accessibility gaps closed (#367). A transcript turning ready or failed is now announced, not only "working". Public pages move focus to their heading on a change of page, as the rest of the app does. The phone Contents sheet keeps Tab inside it and is announced as a dialog. A dialog whose opener has gone returns focus to the page's heading rather than the top of the document, and opens on its first enabled control. The account menu has an Accessibility entry, and the statement lists two more known gaps: small map markers, and status changes not always announced by every screen reader. The public pages Help, Privacy, Accessibility, Licences, auth-error and the not-found page are now scanned by axe in the journeys.
    • [webapp] The admin tables for provider health, notices, backups and a person's campaigns can be scrolled from the keyboard (#367). Each scrolling table is a named region you can Tab to, as Admin → Usage, Users and Audit already were, so it can be read on a narrow screen without a mouse.
    • [webapp] Links inside a sentence are underlined where axe found them told apart by colour alone, and Admin → Usage's tables scroll from the keyboard (#367).
      The links are on About this server, Settings → Ownership and the wiki's Rules page (WCAG 1.4.1). The three tables on Admin → Usage can now be reached and scrolled from the keyboard on a narrow screen. The journeys' axe scan now also covers every campaign, wiki, session, profile and admin page no journey reached.
    • [webapp] A card's notes for tonight are no longer lost when the card
      closes mid-sentence.
      The notes save a second and a half after you stop
      typing. Closing the opened card, or the stage moving to another card, inside
      that moment threw away what had just been typed. A save still waiting is now
      sent as the field goes (#536).
    • [webapp] A character can no longer be given a name that another character
      is "heard as".
      Listing "Wyatt" as a misspelling of Idani and then naming a
      second character Wyatt was accepted, and from then on that misspelling
      silently stopped being corrected in transcripts, with nothing to say why.
      Creating or renaming a character, finishing a sheet, or assigning an imported
      member to a name like that is now refused, saying whose misspelling it is. A
      campaign already in that state is unaffected: no transcript was ever
      rewritten wrongly, and its characters can still be edited (#634).
    • [webapp] Shortening how long transcripts are kept no longer destroys them
      on the next nightly run (#680).
      A transcript past its campaign's window
      now goes to a bin first, as a recording does, and is deleted for good only
      once the bin's grace has passed — the same number of days the bin keeps
      audio, never fewer than seven. While it is in the bin nobody can read or
      search it, it is in no export and is sent to no model; the session's summary
      and highlights are untouched. The campaign's GMs are told three days before
      it is deleted (one notification when a session's recording and transcript
      are due together; nothing new is posted to Discord), and a GM can restore it
      from the session's Review tab. Lengthening the window again lets every
      transcript it no longer reaches back out of the bin on the next run, so a
      window shortened by mistake is undone by putting it back. Erasing a member
      still reaches a transcript in the bin. Settings → Recording, retention & AI
      now says where the transcripts go and for how long, instead of "There is no
      bin behind it". Upgrading runs one migration
      (sessions.transcript_trashed_at, sessions.transcript_deletion_warned_at);
      a transcript the old sweep already deleted is not brought back by it. See
      docs/OPERATIONS.md → "How retention removes recordings and transcripts".
    • [webapp] A player's Handouts page no longer breaks when a handout was shown to her with no file of its own; it is listed as a page.
    • [webapp] The party page's character links are underlined, not told apart from the text around them by colour alone (WCAG 1.4.1).
    • [webapp] Maps are cut into tiles. The worker mounted the media volume
      read-only, so every map's tiling failed on every install. It is writable
      now; a customised docker-compose.override.yml that redefines the worker's
      volumes must drop :ro from media:/app/media. A failed map now says why in
      words rather than showing the server's error and file paths.
    • [webapp] A party's trail stays in its own campaign. Its routes did not
      check the party's campaign, so a member of one campaign could read another
      campaign's trail by its id, and a GM could add stops to it. A stop on a map a
      player can't see no longer tells her where it is.
    • [webapp] Creating an atlas, a map or a marker no longer fails with a
      server error.
    • [webapp] The first person to sign in to a new server is its owner as well as its administrator, so Admin's "Run by" line and hand-the-server-on work from the first day. The J1 journey now walks Admin's leftovers, Backup and the not-an-administrator stop in all three projects.
    • [webapp] Fixed the Notifications grid's narrow-screen note always saying "always comes to the bell" even for the star-and-wish row, which shows on Home instead — it now names any such exception.
    • [webapp] A mail app's own Unsubscribe button now stops the emails it is on.
      The List-Unsubscribe header named the "Stop these emails" page, which answers
      the one-click POST (RFC 8058) with a 405, so pressing Unsubscribe in Gmail or
      Apple Mail stopped nothing. It now names POST /api/email/stop/one-click; opening
      that address instead shows the page, which asks first.
    • [webapp] A sheet no longer says "Someone else changed this sheet" about
      your own save (#737).
      Leaving a sheet, or a character being started, in
      the moment its changes were being saved kept a copy of them on the device;
      coming back, that copy was sent again and refused as a change made
      elsewhere. What the server already holds is now let go of, and only what is
      really still unsaved is put back.
    • [webapp] A character who has left the party stops collecting its XP
      (#737).
      A retired character's sheet went on counting every award to the
      party, and said a level was waiting. Their own awards are still theirs.
    • [webapp] Each "Choose from the loaded content" on Start a character says
      what it chooses (#737).
      The Ancestry step had three buttons with one
      name; a screen reader now hears which is the ancestry's, the heritage's
      and the ancestry feat's.
    • [webapp] A failed recording's retention clock no longer restarts when its
      session is written to (#798).
      A session whose processing only ever failed
      counted its audio retention from updated_at, which moves on any write to
      the row, so erasing a member from it (the erasure note) or editing its title
      kept the audio for another full window. It now counts from the new
      write-once sessions.audio_received_at, stamped when the bot's audio is
      first accepted. Migration a798c0d1e2f3 adds the column and backfills
      existing failed runs from updated_at, so nothing is caught earlier or
      later on upgrade day. The campaign export and import carry the new field.
    • [webapp] A campaign's export now carries its prep sheets, story arcs and
      the shelf, and an import puts them back (#830).
      A GM who exported a
      campaign to move it, or to keep a copy, lost their prep and their table
      setup. The file (schema 15) now holds each session's sheet, beats, plan and
      items (For later included), the arcs, and the shelf's scenes, pins, cards and
      reveals, each section marked as the GMs'. A pin rides the later sheets and
      shelves as it did, and a card taken off a shelf stays off it. A reveal comes
      back no wider than it was shown (one for named people becomes the GMs'), and
      one taken back comes back taken back. Discord message ids and Workbench runs
      do not travel: a generated or a rules card reads from what it kept. Older
      files import as before. See docs/API.md.
    • [webapp] A paused or unreachable Redis no longer hangs the request that
      touches it, and notify() stops trying recipients once the broker is down.

      Every async and sync Redis client the app builds now has a 2 s connect timeout and a 10 s
      reply timeout (the Celery side already had its own), built in one place,
      app/redis_pool.py; the one client that waits on pub/sub (the live
      recording-status stream) keeps no read timeout. A dead broker used to cost one
      publish timeout per recipient; the first connection failure now skips the
      rest of that batch (the in-app notifications are still written). (#841)
    • [webapp] Notification emails are sent again (#865, #736). The delivery task
      read its SMTP settings on a second event loop inside its own, which cannot start,
      so every per-person notification email since #655 was logged as "Failed to load
      SMTP config" and dropped — including, once lane 10 moved them onto that path, a
      group without Discord's time votes and confirmations (D153). The task now reads
      the settings in its own session and sends off the loop.
    • [webapp] A phone's Contents no longer names Admin twice. With Home's
      new "This instance" group on screen (lane 9 page 4a), the account rows
      below it dropped their own Admin link rather than repeat the door under
      the same name.
    • [repo] Two backend tests stop leaking rows into later test files (#869).
      test_recap_email deletes the SMTP config row rather than nulling it, so it no
      longer breaks test_admin's test-email tests when it runs first, and
      test_backup_offsite cleans up the BackupLog rows its task tests commit.
    • [webapp] The Review checklist's "Recap email" row is truthful before approval (#871,
      D162). Since the recap now fires on approval rather than at processing time, the row had
      no record to read yet and showed a bare "No record". It now reads a server-computed
      forecast — "To four people, once you approve" when the campaign has recaps on and someone
      would get one, or "Skipped" with why (recaps off, or nobody on the attendance list has
      opted in) — the same check the send itself makes, made read-only.
    • [webapp] Admin → Campaigns lists the campaigns in the bin, dimmed, with the day
      each goes for good, as the drawing does.
      The page never asked the server for
      them. GET /api/admin/campaigns gains purge_at. Its Hand it on… dialog also
      hands on to the member it shows selected, where it sent an empty choice unless
      the list was touched first. A server with no campaigns says so (#872).
    • [webapp] Admin → Campaigns' "Keeps its own" column says what a campaign keeps
      ("Audio 90 days", "Transcripts for good") instead of always "Its own settings",
      and a failed recording's error no longer ends in ".”." (#872).
    • [webapp] Admin's Audit, Users and Campaigns tables scroll sideways on a phone
      instead of wrapping every cell over several lines.
      Each table has a width of
      its own, dates and short cells stay on one line, and the scrolling box is
      named and reachable by keyboard (#872).
    • The GM who confirms a session, books one directly or moves it is no longer sent a bell row and an email about it; the rest of the table still is (#887).
    • [webapp] A page's picture is as private as the page (#894). Pictures were stored publicly, so a GM-only page's picture could be opened by anyone with its address. They are stored privately now and served only to people who can read the page. A picture uploaded before this moves to the private store the first time it is shown, and its old address no longer works. The backend's own media mount (used where nginx isn't in front of it) no longer serves private files either.
    • [webapp] A player's wiki save can no longer rename an item that isn't
      identified yet (#912, IS-122). A player only ever reads the party's name
      for such an item, never its real title, so PATCH .../lore/{id} now keeps
      the real title silently if she sends back what she was shown, and refuses
      the save with a 403 if she sends anything else — a GM's rename still works.
    • [repo] Fixed every upload failing on a fresh install: the backend image now creates /app/media owned by its own user, so a new media volume is writable (#914).
    • Choosing people to show something to, then clicking the next control in the dialog (such as "Send it to them on Discord"), now does both; the first click is no longer lost (#918).
    • A session page no longer flips back to what it said before a GM's action (Withdraw, say) when a background refresh that left earlier lands late (#932).
    • A slow answer can no longer overwrite a newer one anywhere the app re-reads while it waits: the Review tab, the vote results fold, and the Try again buttons (#941).
    • [webapp] A raw DELETE FROM campaigns no longer fails when a quest
      objective was done in one of the campaign's sessions (#943).
      The objective
      was reached by a cascade through its quest and a SET NULL through the
      session in one statement; its quest foreign key is now deferred to commit.
      The demo seed's delete-order workaround for it is gone.
    • [webapp] The demo seed's played sessions load again (#978). The seed wrote each played session's characters into canonical_names, which holds the name generator's picks, so every one of them failed with "This session could not be loaded"; a test now builds every seeded session's page as its GM and as a player.
    • [webapp] One star, one wish asks about every campaign that played, at
      once.
      A player in two campaigns that both played last night was asked
      about one, and the other appeared only after answering and reloading,
      which read as the same question twice. Home now shows one card per
      campaign, each naming its campaign when there is more than one, and
      GET /api/me/star-wish returns prompts (with prompt kept as the
      first of them) (#980).
    • [webapp] Campaign Settings → Recording's "Test the bot" box has room inside
      its border.
      Its text and buttons ran right up against the edge; the box now
      has the same inner padding as the AI panel above it.
    • [webapp] The models that write about people are now told their pronouns, so a saved recap no longer has to guess. The session summary, its event log and key moments, the wiki suggestions, /ask, the wiki's draft tools and one-line page summaries, and the GM's prep tools each get the pronouns of the people they can name, and only of those who have set some: players' (from their account), their characters', and those on wiki pages the table can read. A player's and their character's are sent as two separately labelled lists and never stand in for each other, and the model is told to use a name or singular they for anyone not listed rather than guess. Nothing is copied: they are read each time a prompt is built, so clearing them, leaving a campaign or deleting an account takes them out of every later prompt. Pronouns are personal data and now go to the configured model along with the names and transcript that already did; /privacy and docs/OPERATIONS.md ("What the LLM is sent") say so. (#999, finishes #653)
    • [webapp] A tap on a phone no longer goes missing while the page is still
      scrolling.
      The page's scroller had scroll-behavior: smooth, which
      animated every scroll — the browser's own scroll to whatever took focus, a
      restored position, the scroll to a button about to be pressed — and a tap's
      click, which a phone sends a moment after the finger lifts, landed on
      whatever had slid under it by then. It was J5's intermittent phone-only
      failure (Strike, Reword and "Accurate" not taking). Scrolls are now
      immediate; a jump you ask for (a Contents entry, an event's "at 1:09:12")
      still glides, unless your system asks for reduced motion.
    • A scheduling test no longer fails on Tuesdays: it put a session past the 14-day RSVP horizon on that one weekday.
    • [webapp] The web fonts are back. No production build had shipped a
      font file since the design system moved to self-hosted fontsource faces, so
      every page rendered in the browser's fallback sans-serif: Tailwind's PostCSS
      pass lost the path Vite needed to find the font files, and Vite only warns
      about that. The faces are now imported from JavaScript, and Vite emits them
      with hashed names (none inlined, which the CSP would refuse). npm run build
      now fails if any url() in the built CSS points at a file that is not there,
      and nginx answers a missing /assets/ file with a 404 instead of the app's
      index.html (#891).
    • The wiki no longer names the demo campaign to everyone: its front page, a group and Recent changes said "the Amber Coast" and "Dana" in every campaign. They say "the GMs" now (D174).
    • A wiki change reads "Dana started it", not "Dana new page it", on the front page and in Recent changes.

    Security

    • [webapp] The add-member and attach-to-an-account search needs two letters
      before it finds anyone (#702).
      One letter, or an empty query, used to list a
      slice of every account on the instance whichever scope was set; it now answers
      nothing, and the page does not ask until two letters are typed. % and _
      in a search are plain characters rather than wildcards.
    • [webapp] An unidentified item's true name no longer reaches players
      outside the wiki (#877).
      A page with a name the party knows it by ("A
      tarnished silver ring") showed that name on every wiki read, but not
      everywhere a page is named. A player could read the real one in the
      page's History, in the bell line, email and Discord post for a handout
      shown to them, in a reveal's own title, in "another page is already
      called that" when they guessed it, in a campaign timeline row with no
      words of its own, in the quest log, the bot's /quests, the pre-session
      reminder's open threads and the session page's open loops, in "Add to a
      sheet", in the name a new character sheet starts with, in "… reached
      level 7" and in a rest's "… has no hit dice". The wiki's list was also
      sorted by the real name. Each now uses the name the reader knows; a GM
      still reads the real one in their own views, and words written once for
      everyone (a reveal, a Discord post, a milestone) carry the party's name.
      A new test lists every read of a title in the server and fails on one
      nobody has classified (tests/wiki/test_title_readers.py).
    • pypdf, which reads the page count of uploaded handout PDFs, is on 6.19.0 rather than 5.1.0, clearing the parser advisories pip-audit reports against it (#930).
    • [bot] pyjwt 2.13.0 → 2.14.0, clearing ten advisories (CVE-2026-101917, CVE-2026-102265 to 102274) against the version the bot pulled in through redis.
    • [frontend] Dev tooling: brace-expansion and fast-uri patch bumps, clearing four advisories (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p, GHSA-hrr3-gc8f-f4qj) in build-time dependencies; nothing shipped to the browser changes.
    • [webapp] A wiki page in the bin is no longer in a campaign's export, so
      an import can no longer bring it back live (#990).
      The export wrote a
      binned page like any other, and the import has no way to put a page in the
      bin, so it came back as a live page with the audience it last had: a player
      of the new campaign could read what the GM had taken away. A soft-deleted
      row now stays behind, with everything in the file that hangs off it — the
      page's Markdown file and picture, its aliases, owners, name targets and
      relationships, any reveal of it, and the wiki cards, pins and prep items
      that carry a copy of it. A row that pointed at it keeps its own words and
      loses the link. The import also skips any row a file marks as deleted
      rather than importing it live. Nothing that is only ended is left out: a
      cancelled session, a hidden post, a reveal taken back and a card taken off
      a shelf travel as before. An archive made before this release may still
      contain binned pages, which import live — export the campaign again
      instead of importing an old file.
      See docs/API.md.
    • [webapp] The draft tools (rephrase, expand backstory, promote, stat block, merge) and the relationship-proposal prompt now show the model an unidentified item by the name the table knows it by, so a model can no longer write its true name into text meant for the table. What a GM reviews and what an approval writes back are unchanged, and an approved proposal never renames the page to its player title. A proposal about an unidentified item now finds its page by the table's name instead of arriving as a new page (two items sharing a player title are not guessed between). (#993, follows #877)
    • [bot] pyjwt 2.14.0 → 2.15.0, clearing CVE-2026-101918 against the version the bot pulls in through redis; and [webapp] serialize-javascript 7.1.1 → 7.1.2 in the frontend's lockfile (GHSA-gfhx-hw2g-v5hg), a build-time dependency that is not shipped (#995).
    Downloads
  • v4.5.0-rc1 93d0a06740

    v4.5.0-rc1
    Some checks failed
    CI / Docker image build (push) Successful in 22s
    Release / Create Forgejo release (push) Successful in 32s
    CI / Does this change touch the recording path? (push) Successful in 39s
    CI / Bot/backend version sync (push) Successful in 1m8s
    CI / Backend lint (ruff) (push) Successful in 1m14s
    CI / Feedback site tests (push) Successful in 1m30s
    Release / Build and push versioned images (push) Failing after 1m24s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m59s
    CI / Bot tests and audit (push) Successful in 3m4s
    CI / Frontend (shard 2/2) (push) Successful in 9m13s
    CI / End-to-end journeys (phone) (push) Successful in 9m39s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 9m37s
    CI / Frontend (shard 1/2) (push) Successful in 10m50s
    CI / End-to-end journeys (keyboard) (push) Successful in 10m19s
    CI / End-to-end journeys (desktop) (push) Successful in 10m45s
    CI / Backend migration, tests, and audit (push) Successful in 35m58s
    Pre-release

    claude-bot released this 2026-10-03 15:28:44 +00:00 | 876 commits to main since this release

    Prerelease of 4.5.0 for testing on the dev server; not for production.

    Added

    • [webapp] The bell reaches older notifications with "Show older". The
      panel showed only the newest twenty; a button at the foot of the list now
      adds the next twenty, moves focus to the first of them, and goes away when
      nothing older is left (#1000).
    • [webapp] Setup's AI step asks for the model's context window. It is
      the same field as Admin → AI models ("How much it can read at once"), saved
      through the same setting, so a server set up through the wizard no longer
      falls back to 32,768 tokens without anyone having been asked (#1001).
    • [webapp] In a campaign that levels by milestones, the GM can hand a level
      to the player (#1007).
      Until now only the GM could level a character
      there. Beside Level up in the sheet's header a GM now has "Let Priya level
      up": it marks the character's next level as waiting, the player is told in
      the bell, and their sheet says "Level 4 is waiting" with Level up and Not
      yet, exactly as it does in an XP campaign when the XP is there. The GM's
      header then reads "Level 4 is waiting for Priya", with Take it back.
      Reaching the level clears the mark, whether the player does it or the GM
      levels the character as before. It is one level at a time, a lock on Level
      still stops the player, and the server refuses a player's level-up in a
      milestone campaign unless the mark is set. Marking a level makes no version
      of the sheet, is not part of a sheet's export, and nothing about it goes to
      email or Discord. An XP campaign is unchanged: its levels wait on the XP.
      The demo seed's Hollow & Ash now levels by milestones, with Tam Ridley's
      level 4 waiting. Migration c1007a1b2c3d adds three nullable columns to
      character_sheets and the level_waiting notification kind.
    • [webapp] A result in the prep sheet's Rules & bestiary and Wiki drawers
      can be read before it is inserted.
      Its title opens the entry in the
      drawer, in the results' place — Table mode's own Find preview: a wiki page
      as the GM reads it, a creature as its block, a rule as its text — with
      Insert still there and "Back to results". Escape goes back too, and focus
      returns to the result it came from. It works the same in the phone's Tools
      sheet (IS-472).
    • [webapp] The campaign search has a page of results, and a rule you find opens
      where you are (#1033).
      The results under the search box now show three of each
      kind — sessions, people, wiki, rules — then "All 14 in Wiki" and "See all 23
      results", which lead to a page listing every match, grouped the same way, with a
      row of links to narrow it to one kind. Pressing Enter in the box goes there too.
      Until now a kind stopped at ten with no way to the rest, and "Rules · 10" could be
      hiding more. The page has no search box of its own: the one in the bar is still
      the only one. A GM gets the same "Look inside transcripts" switch on the page, and
      a player still never sees a GM's material there. Choosing a rule used to do
      nothing; it now opens the rule in place — its text, or a creature's block — and
      closing it puts you back on the result you chose. The wiki's own search box works
      the same way. A kind with more than 200 matches lists the first 200 and says how
      many there are.
    • [repo] The dev demo seed has lane 8's wiki and lane 10's bot tests. Both
      parties and who has been in them, reputation, downtime, custody and an
      unidentified ring; the 27 quests as pages with givers, rewards and
      objectives; mid-text :::gm, :::for and :::party blocks and pages for
      every audience; six handouts with their files and reveals; private page
      pictures; three atlases with maps drawn and tiled at seed time, markers of
      every kind and the party's trail; players' suggestions (the Tuesday Tide is
      now "Players suggest, I decide"); the link index and Recent changes; and six
      finished bot tests with what they spent. Files go under
      media/private/, only after the commit. The map tiling task's cutting is
      now a pure cut_tiles the seed calls directly (#734, #736).
    • [webapp] An article's read view, History, More, Recent changes and Deleted
      articles (lane 8 PR P2, #734; WIKI-3, WIKI-5, WIKI-6, WIKI-17, WIKI-18).
      • An article (/wiki/:entryId) renders the body through WikiText
        (PR P1) with the server's own ?as_user= for a GM's "See it as…"; a page
        outside the reader's audience says only "This page isn't shared with you"
        (W3). Relationships, appearances, mentioned-in, references and a timeline
        follow, with a margin for the picture, facts, a stat block, and (an item
        page) who has had it.
      • History (/wiki/:entryId/history) restores through the server's own
        POST …/versions/{id}/restore, replacing the old article page's plain
        PATCH, which never called it.
      • The ⋯ More menu: adding to a session's shelf, the three LLM drafts,
        Convert the stats, merging a page into this one, and deleting with an
        undo — replacing the last alert()-shaped delete in the product.
      • Recent changes (/wiki/recent) and Deleted articles
        (/wiki/deleted, GM only) round out the wiki's four remaining P1/P2
        surfaces; the front page's "All recent changes" now leads to the former.
      • Lane 7's Read · Talk · History tab bar is kept; History is now a plain
        link to its own page rather than an inline panel.
      • The read half of WikiArticle.jsx is deleted; its edit half stays at
        /wiki/:entryId/edit until PR 3 rebuilds the editor.
      • Frontend only: no backend or bot change.
    • [webapp] A draft, Suggested wiki updates and Wiki health on the new wiki
      (lane 8 page P4, #734; WIKI-9, WIKI-10, WIKI-16).
      • A draft shows the page beside the draft with each stage in plain
        words, typed stats, Send it back and Throw it away. A player's
        suggestion opens here too, compared line by line with the page as she
        reads it
        , never the GM's text.
      • Suggested wiki updates shows where each session's suggestions stand,
        then the pages and relationships waiting, one action at a time.
      • Wiki health lists wanted pages, unlinked mentions, orphans, possible
        duplicates and stale pages, each with its fix.
      • The Contents gains Wiki health for a GM, and Suggested wiki updates shows
        its count.
    • [webapp] The wiki's editor, a new page and Settings → Wiki, rebuilt on lane 8's
      text (WIKI-7, WIKI-8, CAMP-14, D72, #734).
      Write on the left and the page on the
      right (Write | Preview on a phone), with a toolbar, [[ autocomplete, and ◐, which
      opens the audience picker and writes a :::gm, :::party or :::for block for
      whoever was chosen. One Save sends the text, the fields, the facts and the timeline
      together; the text is kept on the device as it is typed, per person. A save that
      started from an older version says who saved since and offers merge by hand, use
      theirs or keep mine. One heading's section can be edited on its own. Pasting a
      picture uploads it to the page's private files; pasting from a document brings its
      headings, bold and lists across as Markdown. A GM sets who sees the whole page
      (D166). Relationships, "Who plays them", the picture, the stat block and links you
      could add moved in from the old edit page. A player's save is only ever her own
      copy of the page, edited:
      the blocks she can't see are never in the editor, and
      the server puts them back where they were. A new page starts from its kind's
      template; where players suggest, it goes to the GM's queue. A name two pages
      share opens a page listing both (D72), and "See it as…" can now pick a character.
      Settings → Wiki sets who may edit, what each kind of page starts with, and the
      reputation levels. WikiArticle.jsx, WikiNewEntry.jsx and WikiSearch.jsx are
      gone.
    • [webapp] History per reader, Recent changes, Wiki health, merging two pages,
      personal notes, and canon in a block
      (lane 8 wave 2 PR 2b, #734; IS-051,
      IS-055 to IS-060, IS-105, IS-147, IS-148, D1, D69, D197, D219). Server only.
      • History (GET /lore/{id}/versions) opens to anyone who may edit the page,
        not only the GM and the primary owner, and drops a version that changed
        nothing this reader could see. Restoring an earlier version widens the same
        way: a character's own player may restore her own page, safely — her restore
        is merged against the live body, never overwriting a block she can't see.
      • Recent changes (GET /wiki/recent) is one feed — made, saved, deleted,
        put back, renamed, merged — filtered so nobody learns that something they
        cannot see has changed. Migration 8c3d4e5f6a7b adds wiki_events.
      • Wiki health (GET /wiki/health, GM-only): wanted links, unlinked
        mentions, possible duplicates, orphans and stale pages.
      • Merging two pages (IS-060): "Merge or Keep both" starts the existing
        LLM-merge draft (POST /lore/{id}/merge); approving it now also moves the
        other page's aliases, links, relationships, references, appearances, owners,
        personal notes and Discussions threads onto the survivor, and trashes it.
      • Personal notes (IS-105, lore_personal_notes): a player's own notes on a
        page, read by the writer and every GM (D197), and never AI context.
      • Canon answers wrap in a block (IS-148, D219) instead of being refused: a
        party or private answer is written as :::party or :::for, naming a player
        as well as a character, pre-filled from the thread's or post's own audience.
        A new page made this way starts at that same audience, so its title doesn't
        leak to someone who can't read the one block on it. Every canon write adds a
        decided in footnote automatically, and a backfill adds one to every post
        already made canon.
      • The NPC tracker folds into the wiki (D1): the migration makes a People
        page for any tracked NPC with no page of that name, carrying its session
        appearances. The old tracker stays working for one more release.
      • The 60 s wait before wiki proposals land is now usually instant: each
        match pass fires consolidation itself once every category is in, rather than
        waiting for the 60 s backstop — which still runs, and now waits longer for a
        slow local model before giving up.
    • [webapp] The wiki's front page and a group, rebuilt (lane 8 PR P1, #734;
      WIKI-1, WIKI-2, D60).
      • The front page (/wiki) leads with a search box scoped to the wiki and
        the rules, then "Recently revealed or updated" (the top of Recent changes,
        D60), "People you've met" (appearances the reader may see, most recent
        session first), and Browse by group. A GM's copy also carries "Waiting for
        you" (the top of Suggested wiki updates) and Wiki health's counts.
      • A group (/wiki/g/:group) lists a group's pages A to Z, narrowed by
        type with a chip when it holds more than one — People, Places, Groups and
        Things are now Contents entries; Events and Lore stay reachable only from
        Browse by group. WikiFront.jsx and its test are deleted.
      • WikiText (src/wiki/): the parser and renderer for the wiki's block
        syntax (:::gm, :::for, :::party, footnotes, [[links]]) that PR P2
        wires into the article page — built here, tested, and not yet on any page.
      • Frontend only: no backend or bot change.
    • [webapp] Handouts, private files, and rules' "Make it ours" (lane 8 wave 2
      PR 5, #734, §3.8, §3.10; W7, W8). Server and the bot's own side; the pages
      follow.
      • A handout is a document wiki page with a file (lore_attachments): PDFs
        are validated by magic bytes with their page count read, images re-encoded
        through the existing picture pipeline. Migration 8a7b8c9d0e1f, on top of
        lane 8's quest pages.
      • Private files stay private (W7). A file narrower than the table no
        longer lives at a guessable, cached-forever address: GET /api/campaigns/{id}/files/{attachment_id} decides on every request whether
        the caller may have it — the page's own audience, or a live reveal's — and
        answers with X-Accel-Redirect to a new internal nginx location that
        refuses a direct request.
      • Reveals carry an audience, a page and an optional session (W8).
        shelf_reveals gains lore_entry_id, piece_label (for a torn letter
        shown in pieces), and lane 7's audience columns; session_id is now
        nullable, so a handout can be shown between sessions. A handout's own
        audience is the union of its live reveals.
      • handout_shown, a bell kind ([wiki] category) and a new, additive bot
        event: the bot fetches the file with GET /api/bot/files/{id} and posts it
        to the campaign's Discord channel, or DMs it to each named person instead of
        posting to the channel. BOT_CONTRACT_VERSION stays 2.
      • "Make it ours" (POST /api/reference/{id}/make-ours): a rules-pack
        entry becomes a GM-only campaign page that keeps a link back
        (source_reference_entry_id), so the rules chapter needs no endpoint of its
        own.
    • [webapp] Handouts and Rules & bestiary, two new chapters of the wiki
      (lane 8 page P6, #734; WIKI-14, WIKI-15).
      • Handouts (/wiki/handouts): a GM's handouts by session with who each
        went to, the ones not shown yet first as GM prep, and a reveal outside
        play as "Between sessions". Show it picks who (the audience picker,
        characters first), posts to Discord unless switched off (D61), says
        when, and names the piece of a letter shown in parts; Take it back
        uses the server's own revoke route. A player reads what was shown to
        her, by session, "Just you" on hers alone, and may add one (just her and
        the GMs) where the campaign lets her write pages.
      • A handout's own page shows its file: an image, a PDF previewed in the
        browser's viewer with "Download the PDF" beside it (download only on a
        phone), or plain text. Every file is read through the authorised files
        route, never a /media/ address.
      • Rules & bestiary (/wiki/rules): search the campaign's loaded rules,
        each hit tagged as rules; a rule opens with its source, a creature as a
        stat block, and a GM can Make it ours. The licence line for each
        loaded pack sits at the foot; with no pack loaded the page says where a
        GM loads one.
      • Server (#916): a live reveal that covers a player opens that
        handout's page to her to read (rendered for her, so GM blocks never
        reach her), and nothing else — not the wiki's lists, search or any
        write; taking it back closes it again. GET /api/campaigns/{cid}/handouts
        lists the handouts shown to the reader, grouped by session. A player's
        own handout page is hers and the GMs'. A page made with "Make it ours"
        says which rules entry it came from.
      • Both chapters join the Contents' wiki group.
    • [webapp] The wiki's links, references and names, one Save, and who may edit
      (lane 8 wave 1 PR 2a, #734, #779; IS-033, IS-036, IS-037, IS-042, IS-046, IS-048,
      IS-049, IS-053, IS-054, IS-085, IS-088, IS-399; D72). Server only; the pages follow.
      • Links are records. Every [[link]] and footnote in a page's text is written
        to lore_links / lore_references on each save (and backfilled by migration
        8b2c3d4e5f6a), each with the block it sits in, read per reader: "Mentioned in"
        never names a page, or a link inside a block, the reader can't see. An article
        now says what each name it links means to its reader (a page, the
        disambiguation page, plain words for a page they can't see, or wanted), and
        lists the footnotes they may open (https, a thread or post, a moment in a
        session, a rule), numbered for them. The server never fetches a URL.
      • Names (D72). A name another page has is a 409 that says what to call the
        two ("Kettlebridge (town)", "Kettlebridge (boat)"); the bare name then opens a
        disambiguation page (GET /lore/by-name/{name}), unless a GM points it at one
        page. A renamed page keeps its old name as another name, and can rewrite the
        links that use it; a renamed character keeps their :::for blocks.
      • One Save. A save sends the version_id it started from; if someone saved
        since, it is a 409 carrying the page as it now is, and nothing is saved. A
        phone can save one heading's section (GET /lore/{id}/sections/{n}).
      • Who may edit (Settings → Wiki): only the GMs, players their own pages (the
        default, and today's behaviour — now including pages they made, which they may
        start), players anything, or players suggest. A suggestion — a new page, an
        edit, a relationship — waits in the GMs' queue and is merged as its writer, so
        nothing they couldn't see is touched.
      • Relationships have an audience of their own and may start and end in a
        session. Templates per kind and the reputation levels (Pathfinder's
        seven to start) are the campaign's to change; exports carry all of it.
    • [webapp] The map, a new chapter of the wiki (lane 8 page P7, #734; WIKI-11).
      • The map (/wiki/map) draws each map over its private tiles, with the
        atlas's levels as crumbs, filters by kind, the party's trail (a stop per
        place, its sessions and days on the road) and "The party is here". A
        marker opens its own map, travels a link, or opens its page; a GM reveals
        a GM-only marker to the players, with an undo.
      • A player adds her own markers, for herself and the GMs, the party, or the
        table (IS-076). A GM adds a map, as its own atlas or opened from a marker,
        and where the party ended a session.
      • Every marker is also listed under the map, and the map pans with the arrow
        keys and zooms with its buttons.
      • The wiki's front page says where the party is (IS-124), and an article
        says where its page is on the map (IS-102, IS-087).
    • [webapp] The party, what it carries, where it stands and what it did between
      sessions
      (lane 8 wave 2 PR 3, #734; IS-107 to IS-122, IS-088, IS-116, IS-206,
      IS-218 to IS-221; D168, D198, D12). Server only; the party page follows.
      • Parties. Every campaign has a party, and may have more (/parties), with
        who has been in each over time: joined and left at a session, with why
        (left, retired, died, captured, swapped), and companions, hirelings, mounts
        and vehicles as pages. Swapping characters keeps the record in step. Migration
        8d4e5f6a7b8c makes each campaign's party and places every character at the
        first session they were played in.
      • "The party" names one party (D168). Threads, posts, pages, relationships,
        facts and blocks can be for a party by name (:::party The Tuesday Watch);
        everything already for "the party" is the first party, so with one party
        nothing changes, and a renamed party keeps its blocks.
      • Items belong to characters (D198): a line is held by a character, the
        party's pool, an NPC or a place, can link its own page, and records who has
        had it. An unidentified item is named as the party knows it everywhere a
        player reads it, until a GM identifies it. Existing lines, XP and gold went
        to the character each player was playing in that session.
      • Removing a line, an award or a movement is undone (D12), not confirmed.
      • Where the party stands with each organisation, changed at a session, on
        the campaign's levels; and downtime, logged by the player and approved by
        the GM, with Pathfinder's activities to pick from.
    • [webapp] The party and the quest log, as wiki pages (lane 8 PR P5, #734;
      WIKI-12, WIKI-13).
      • The party (/wiki/party): each party's home base, fund, XP pool,
        members with pronouns and companions, and active quests; What we carry
        (notable items, then the ledger, filtered by holder; add, give, change);
        XP and gold side by side with each character's total; downtime with
        Pathfinder's activities to pick from and a GM's Approve; Who has been in
        it, current then former, with moving a character to another party and
        why; and Where we stand in the margin. Removing a line is undone, never
        confirmed (D12). A player adds to the ledger only where the campaign
        allows it and changes only her own lines (D6); an unplaced row counts in
        the main party (D236).
      • Quests (/wiki/quests): Rumours waiting for the GM first, then each
        status, with its colour, a failed quest struck through, when it started,
        and "The quest log did not load" rather than an empty list. A new quest is
        a new page, opened in its editor.
      • The Contents gains Quests and The party under The wiki; "Quests & loot"
        goes. /campaigns/:id/manage now redirects to the party page, and the
        old campaign page is deleted with LootLedger, QuestLog and the
        components only it used. An ownership offer is answered with Accept and
        Decline on This week's banner itself.
      • Frontend only: no backend or bot change.
    • [webapp] Quests are wiki pages now (lane 8 wave 2 PR 4, #734, §3.7; D196).
      Server only; the quest page follows.
      • Every existing plot_threads row becomes a page of kind quest, keeping its
        title, status, sessions and reward note: the GM's private notes move into a
        :::gm block at the top of the body, and player_visible becomes the page's
        own audience — table when it was visible, gm when it wasn't. Migrations
        8e5f6a7b8c9d and 8f6a7b8c9d0e.
      • A rumour may be about no quest yet (D196/W10). Confirming one offers
        "Make it a quest," which creates the page and posts it under whoever started
        the rumour.
      • The bot's /quests, the pre-session reminder's open threads, Prep's context,
        the shelf's open loops and the Quests drawer's quick add all read quest pages
        now; GET/POST/PATCH/DELETE .../planning/threads and GET .../quests are
        removed in favour of GET/POST .../lore/quests, filtered per reader like any
        other page. The bot's response shape is unchanged — no contract bump.
    • [webapp] "Test the bot", a GM's way to check the recording pipeline
      before the evening without a real session (lane 10, PR 7a, #736, D124).

      Settings → Recording, retention & AI gains a launch (30 seconds, stoppable
      early, keep-or-delete after), the running stages, the result — per-stage
      timing, the transcript, the summary, and a plain sentence naming who can
      fix a failure — and the kept test runs, with a confirmed delete (D12). The
      session page offers a link to it while Start is refused for being early
      (D149, D222).
    • [webapp] Two public pages for an email's links, the notification grid's
      Discord DM column, and the installed app's update and offline notices
      (lane 10 PR 7b, #736).
      /email/stop and /email/answer work signed out
      (D154, D155): stopping a kind of email needs one click and never reveals
      more than the token names, and answering "Are you in?" records the RSVP
      through the same writer the session page uses. The grid's DM column shows
      "Discord can't reach you" when the bot's last message failed, without
      hiding the switch (HOME-6), and the star-and-wish ask is its own indented
      line under Sessions (D232). The installed app now offers a quiet "Quest
      Board has been updated" notice instead of reloading silently (D13), says
      so while offline, and no longer locks to portrait orientation.
    • [webapp] Email grew a one-click stop, answering "Are you in?" without signing in,
      and a proper recap timing (D154, D155, D162, O7, O8, O15, #736).
      Every outbound
      email now goes through one template, with List-Unsubscribe/List-Unsubscribe-Post
      headers (RFC 8058) and a footer link to /email/stop (POST /api/email/stop, with an
      undo). Session-confirmed and reminder emails carry "I'm in / Maybe / I can't" buttons
      that record an RSVP through /email/answer, good until the session starts. Calendar
      invites carry the session number in SUMMARY and a URL: back to it, no longer ask
      for a reply (RSVP=FALSE — nothing read the old one), and put a cancellation's reason
      at the top of DESCRIPTION. The post-session recap now sends when the GM approves the
      summary, not the moment it is first written, and never twice. A developer-only
      GET /api/dev/email-preview/{kind} (behind ENVIRONMENT=development) renders every
      template from invented fixture data, for reviewing them without real mail.
    • [webapp] The star-and-wish ask is its own row under Sessions in notification
      settings (D232, O29, #736).
      It used to share the "Sessions" cell; existing
      preferences carry over as the new row's starting value (migration 10d4e5f6a7b8).
    • [repo] Journeys J2 and J4b cover lane 10 (#736). J2 asserts what reaches
      the bot — a vote's number and closing time, the confirmation's number, a ❔ from
      Discord showing on the session page, the cancel reason, and a Discord DM with the
      bell's line — and what reaches Mailpit: the confirmation to the player's own
      address, the time in her zone, the unsubscribe headers, answering from the email,
      and Stop → Undo → Stop. The new J4b tests the bot from Settings → Recording and
      checks nothing else happened, and that a recording whose announcement lands
      nowhere still records and says so (D171). The fake bot hands a test's
      generated near-silent WAVs over as the real bot does. J2's bell check no longer
      depends on how many rows earlier runs added (#887).
    • [repo] The dev demo seed has character sheets and stat blocks (lane 11,
      wave A).
      Every sheet is made through the sheet services the routes call,
      from the mini character packs loaded as campaign packs. The Amberwake Accord
      (and its GM copy): Ossian Vale exactly as drawn, Wren's Spells page and focus
      pool, Bastian, Sister Quill, the owner's Hollis (Level & XP locked by the
      GM, "just you and the GM" campaign notes, and a row from the disabled Salt
      Crown pack that keeps its name and numbers), retired Aldous and Hesper's old
      sheet; conditions and hurt HP on two; every old stat block converted, Odile
      Marsh filled out to the drawing, one block partly shown to the party, one
      wholly the GMs', one projected from Hesper's sheet; a rules creature on
      Session 24's shelf with its own hit points; histories of 3 to 6 versions.
      And a new thin 5e campaign, Hollow & Ash, where the owner plays Tam
      Ridley (Fighter 3, HP 31 from rolls of 8 and 7) with four more characters
      and two blocks.
    • [webapp] Sheets reach Admin, the rules, Prep and Members & characters (#737).
      Admin → Content packs lists every sheet definition on the instance — built
      in, from a pack, or made from a pack's older stat blocks — with its version,
      whether it is active or deprecated, how many sheets are on it and how its
      fixtures did; a pack's row names its own definitions and which sheets are
      still on an older version. Loading a pack that carries definitions shows the
      validator's report for each: activated or not, every error (with a "did you
      mean" where there is one), every warning, and which fixtures failed and why.
      One error stops that definition only; the pack's content still goes in, and
      a campaign's own pack says its definitions are not used. An open rules
      result has Add to a sheet ▾, listing the characters and blocks the reader
      may edit that take it — a player's own character, a GM's every sheet — and
      puts the entry on with where it came from; a reader with nothing to edit
      gets no menu. Instant NPC can Save as a page, with a block: a new NPC
      page with a block drafted for the campaign's game, waiting in Suggested wiki
      updates to be checked (no button where the game has no block to draft).
      Members & characters has Open the sheet on a character whose page has
      one, and the members list names each character's sheet_id.
    • [webapp] Level up on the sheet, with XP from the campaign's ledger (#737).
      A character's XP is now every XP award to them, or to their party, since
      their sheet was made. When it reaches the next level the sheet's header says
      "Level 7 is waiting", with Level up and Not yet; a GM can level any character
      at any time. Settings → General chooses whether the campaign levels by XP or
      by milestone; a milestone campaign shows no XP and its GM says when. Level up
      walks the level in the system's own order: hit points (Pathfinder's worked
      out from the level; 5e's rolled on the class die or taken as the fixed value,
      the die kept with the sheet and the roll standing), the class's own features,
      each feat from the loaded content (one that can't be taken yet is shown,
      disabled, with why), a skill increase as far as the level allows, attribute
      boosts, and 5e's improvement (+2 to one score or +1 to two, none past 20) or a
      feat. What changes lists every number that moves, before and after, and why.
      Reach level n keeps the sheet as it was as a version first, so it can be put
      back, and adds "Ossian Vale reached level 7" to the campaign's milestones, on
      the last session played.
    • [repo] J7, the character journey, and the rest of the demo's sheets
      (lane 11, wave B).
      make journeys now walks a character from the first
      step to level 2, on desktop, a phone and the keyboard alone, with an
      accessibility scan at every page: Priya starts a Pathfinder character from
      Members & characters (ancestry, background and class from the loaded
      content, attributes by boosts, nothing rolled), leaves it and carries on,
      and finishes it; Dana shows Odile Marsh's block to the table by field, and
      Priya is handed the shown lines and no others at the table, on the page and
      in the answer behind it; Dana awards XP, the sheet says "Level 2 is
      waiting", and Priya levels up through What changes to Reach level 2, which
      keeps a version first and adds the milestone. The journeys' seed gains a
      campaign for it, with the sample character pack imported through the GM's
      own import. The dev demo seed gains: "Level 7 is waiting" on Ossian Vale's
      sheet, reached through the XP ledger; Tam Ridley's third level taken on the
      sheet, with its hit-point die kept and "Tam Ridley reached level 3" in the
      campaign's history; and two characters begun and left, waiting on Members as
      "Carry on with …" — one with its 4d6 kept, the lowest die of each struck.
    • [webapp] D&D 5e character sheets and stat blocks, built in (SRD 5.2.1).
      The character sheet works out ability modifiers, saving throws, the
      proficiency bonus by level, all eighteen skills with proficiency or
      expertise and Passive Perception, Armor Class from the armor and shield
      worn (medium armor capping Dex at 2), hit points from each level's rolled
      or fixed hit die plus Constitution, Hit Point Dice, death saves, weapon
      attacks and damage, and spellcasting (save DC, spell attack, slots for full
      and half casters, Pact Magic). Exhaustion and the conditions that stop a
      creature moving change the numbers they affect. Creation (standard array,
      27-point buy, 4d6 drop the lowest), levels 1–20 with the Ability Score
      Improvement or a feat at 4, 8, 12, 16 and 19, short and long rests, and
      the stat block in the SRD's order are all defined. Old 5e stat blocks map
      onto the new block. Includes material from the SRD 5.2.1 under CC BY 4.0,
      credited on the sheet. Nothing on screen yet (#737).
    • [webapp] The block on its page, Show the table by field, Make a block, and the block at the table (#737).
      An NPC's or a creature's page with a sheet draws its block in the game's own
      order under the page's lead. The GM sees every line and which the party can
      read — a GM-only line has the GM's rule down its side, with a legend — and
      has Show the table…, Edit the block, Put on the shelf and Export… beside it.
      Show the table is a checklist by the game's groups (name, level and traits;
      perception and senses; languages; skills; AC, saves and HP; strikes by name
      only, or with their attack and damage; abilities…); GM notes are never
      offered. What is ticked becomes the party's for good, on the page, in search
      and on the table, and unticking a group or Take it back hides it again.
      While a session is being played it is also shown at the table at once — the
      party's lines on the players' screens and, if asked, in the campaign's
      Discord channel — and Take it back takes that showing down, as a handout's
      is, telling the other GMs. That showing opens nothing else: a GM-only NPC's
      page stays the GMs'. A GM can make a block from a character's sheet (Make a
      block…): a new NPC page, the GMs' alone, with a copy of the character as it
      is now that later changes to the sheet do not touch. At the table, Find says
      which pages have a block, and a wiki card or the preview draws the block with
      its hit points (−5 / −1 / +) and conditions changed from the card with no
      version made; a monster from the rules keeps its own hit points and
      conditions on its card for the session. Showing a card with a block to the
      table now asks what to show, by field.
    • [webapp] Rules content now carries the numbers a sheet reads. Every
      reference entry has structured fields per kind — an item's Bulk or weight,
      a weapon's damage and traits, armour's AC and Dex cap, a class's hit die, a
      condition's modifiers — filled when a Foundry PF2e or Open5e pack is
      imported, or written directly in a Quest Board pack (checked per kind; ones
      that don't fit are kept as data with a warning). PF2e ancestries and
      heritages, and 5e species, import as their own kinds. The starter-pack
      scripts now build the Player Core's classes, ancestries, heritages,
      backgrounds, feats and equipment (ORC, setting prose stripped) and the
      SRD 5.2.1 only for 5e; publishing them is a release step
      (docs/OPERATIONS.md, "Building the starter packs"). A small mechanics-only
      character pack per system backs the tests, the journeys and the demo
      (#737).
    • [webapp] The engine that will work out character sheets and stat blocks.
      A sheet's definition (its fields, the sums behind them, the bonuses and
      penalties that apply) is now checked and evaluated on the server: every
      number comes with the named terms it was added up from, bonuses of the same
      kind stack the way the game says, conditions and equipped items apply
      themselves, and a definition that refers to a field that does not exist is
      refused with a "did you mean" suggestion. Ships with a generic sheet for any
      game without its own, and a shared set of test cases the browser will be
      held to as well. Nothing on screen yet (#737).
    • [webapp] The sheet at the table: hit points, conditions, spells, rests, and adding from the loaded content (#737).
      A character's player and the GM change the hot half straight from the sheet,
      on a phone too, with no version made: hit points with −5 / −1 / + and their
      temporary points, hero points, dying and wounded as tracks to press, and
      conditions as chips with − and + (Frightened 1), an "until" note (Off-guard
      until the start of your turn) and Add a condition; every figure a condition
      changes shows the penalty in its terms. A caster's spells have Cast, which
      spends the rank's slot (a focus point for a focus spell) and marks a prepared
      spell expended, and the expended mark takes it back; Refocus sits by the focus
      points. Rest… in the header lists the game's own rests and says what each
      gives back before it is taken: Pathfinder's daily preparations (hit points by
      Constitution and level, every spell slot and focus point, fatigued, drained
      and doomed eased) and Refocus, which is off for a character with no focus
      points and says why; 5e's short rest spends the Hit Point Dice the table
      rolled (the page rolls nothing; Constitution is added for each die), and the
      long rest gives back every hit point and every Hit Point Die (SRD 5.2.1) and
      takes off a level of Exhaustion. Hero points come back to 1 when a session
      starts being played, once per session. While a sheet is open it picks up what
      the other side changed within a few seconds. Editing a sheet now offers Add
      from the loaded content — search, the entry's card (damage, traits, category,
      bulk, price, and where it is from with its licence), then how many and where —
      and each row's own quantity, where it is, invested or equipped, prepared, and
      Remove; a row whose pack has gone keeps its name and says where it came from.
    • [webapp] The sheet engine runs in the browser too. The same sums the
      server works out for a character sheet or stat block can now be worked out
      in the page while someone types, so numbers will update as you edit; the
      server's answer still replaces them on every save. The browser's copy is
      held to the very same shared test cases as the server's, down to how it
      rounds, orders text and prints large numbers. It can also show dice the
      server rolled (which dice were kept and which dropped); the browser never
      rolls. Nothing on screen yet (#737).
    • [webapp] Sheets can name their numbers the way the page shows them.
      A line in the working-out under a number can now take its name from the
      sheet ("Dex 4" rather than "Attribute", "Prof 8" with "trained" beside
      it), a bonus or penalty can name itself ("Frightened 1"), and a sheet can
      pick one name out of a list ("Chain mail 16", "d10 → 8, 7"). A sheet
      definition that misspells a function now gets a "did you mean". Also, a
      malformed item row or an impossible rank no longer stops the whole sheet
      from being worked out (#737).
    • [webapp] A content pack can carry sheet definitions (pack format v2). A .qbpack may list sheets/*.json (and sheets/fixtures/*.json) under sheet_definitions in its manifest. An administrator's pack has each definition checked (unknown names come back with a did-you-mean, fixtures are run) and activated only if it passes; a broken one is refused while the pack's content still imports, and a GM's campaign pack keeps its content but has its definitions ignored, with a note. The import response carries the per-definition report, GET /api/admin/sheet-definitions lists every definition, disabling or removing a pack deprecates its definitions (sheets on them stay readable), export writes them back, and a pack's system.json may now update a game system that same pack introduced.
    • [webapp] Pathfinder 2e character sheets and creature blocks, built in.
      The Remaster character sheet is defined in full: attributes with boosts,
      Armor Class with the armor's Dex cap, saves, Perception and class DC by
      proficiency rank, Hit Points from ancestry, class and Constitution, dying,
      wounded and Hero Points, all sixteen skills and Lore with the armor check
      penalty, Strikes worked out from the weapons carried (attack, the second and
      third attack, damage), feats by level, Bulk and encumbrance, invested items,
      a spells page for casters with slots by rank and Focus Points, and each
      condition's real effect (frightened, sickened, clumsy, enfeebled, drained,
      stupefied, fatigued, off-guard, prone, unconscious, encumbered). The
      creature block follows Monster Core's order, takes over today's stat blocks
      field for field, and can be made from a character. Both carry the ORC
      License notice and no setting names. Nothing on screen yet (#737).
    • [webapp] Character sheets and stat blocks are kept on the server. A
      player character's page, an NPC's or a creature's can now carry a sheet,
      made with the game's own definition (or a generic one for any game without,
      and for a campaign with no system). Every value is checked against the
      definition as it is saved; each field can be shown to the table, the party,
      just its player and the GMs, or chosen people, and a character's player
      always reads the whole of their own sheet. A GM can lock part of a
      character's sheet. Autosaves that cross another window's edit are refused
      with the sheet as it now stands, edits keep a history (one entry per person
      per ten minutes of editing) that can be put back, and hit points and
      conditions change at the table without filling that history. Rows added
      from loaded rules keep their numbers if the pack is later taken down. The
      page's stat block is written from the sheet, and every reader gets only the
      lines they may see. A sheet exports as Quest Board JSON. Nothing on screen
      yet (#737).
    • [webapp] Edit a character's or a creature's sheet, and it saves itself (#737).
      Edit turns a sheet's source fields into inputs of the right kind (numbers,
      text, choices that also take a name of your own, ranks as pips you press,
      rows you add and remove on the generic sheet) while what is worked out stays
      text and moves as you type. The header says where the sheet stands (Saving…,
      Saved at, Not saved with Try again) and there is no Save button; what you
      typed is kept on this device until it lands. If somebody else saved first, the
      sheet says so and shows both versions of each field before you keep yours or
      take theirs. The GM can lock a field or a group, and a locked one reads as
      such to the player; each group that has its own audience can be set to who
      sees it. Every save is a version, and any of them can be put back from
      Versions. A person's or a creature's page with no sheet has Make a sheet, for
      the GM and for a character's own player. Also: a page with no sheet no longer
      ends its crumb on a Sheet that is not there, printing leaves the header
      controls off the paper, a private group's pill names the player by their first
      name, and a derivation whose constant is labelled with its own figure prints
      it once.
    • [webapp] A character's or a creature's sheet, to read: Open the sheet from its page (#737).
      The sheet is a document of its own with its pages (Statistics, Abilities &
      inventory, Notes & actions, and Spells for a caster) in the Contents and, on a
      phone, in the Contents sheet. Every figure is shown once with what it is made
      of beneath it, on tap on a phone; ranks, hit points, dying, wounded and hero
      points, strikes worked out from the inventory, feats by level, the inventory
      with its bulk and where each thing is, spells by rank with how many slots are
      left, and the character's own actions with their cost and full text are all
      drawn from the system's definition, so Pathfinder 2e, D&D 5e and the generic
      sheet are one page. An item whose pack has gone says so and keeps its last
      numbers. What the page itself says (appearance, backstory) is shown with Edit
      on the page; a group a player keeps for themselves and the GM wears the GM
      colour. A reader who may not change the sheet is told so quietly. Export
      gives Quest Board JSON, and Print the sheet prints every page. A page that is
      not a person or a creature says it has no sheet. Read only: editing comes
      next.
    • [webapp] The design system gains the character sheet's pieces: Derivation, RankPips, Track, Resource, Dice, StepStrip, WhatChanges and Picker, each with its states on /design (#737, #364).
    • [webapp] Start a character, in the game system's own steps (#737).
      Members & characters has a Start a character for you, and for the GM beside
      each player. A Pathfinder 2e character walks Concept, Ancestry, Background,
      Class, Attributes, Skills, Equipment and Details; a D&D 5e one walks Class,
      Origin, Ability scores, Alignment and Details. Pathfinder attributes are
      boosts by source, with the cap and how many free boosts are left. 5e ability
      scores come from the standard array (drag a number to an ability, or tap both
      to place or swap them), from point buy with its 27 points and the cost of each
      score, or from rolling 4d6 and dropping the lowest, with every die shown, the
      dropped one struck through, and no second roll where the method allows none.
      The dice are rolled by the server and kept with the sheet. Next says why it
      is held back, each step saves itself, and Finish makes the character's page at
      once, with its prose sections named and empty, and the sheet is the player's.
      A character left half-made is kept for 30 days as "Carry on with …" on Members
      & characters (and on its page), and Open the sheet appears beside a character
      that has one.
    • [docs] Lane 10's drawings, for review (#736, #824): the prototype's lane 8 page draws Test the
      bot (D124, D149, D150), the two pages an email opens (D154, D155), Discord DMs on the notification
      grid (D151, D161), the voice channel and "Where" (D159), and a recording announcement nobody could
      post (D171), and brings its older stills into line with D96, D97, D123 and D151–D163. The owner's
      answers to its questions are recorded as D221–D232, and Settings → Discord is redrawn with pickers
      for the server, channel and voice channel (D226). Catalogue rows IS-443 to IS-458;
      coverage/lane8.py and the CAMP-12, CAMP-13, SESS-2, TABLE-4 and HOME-6 scripts classify them.
    • [bot] The bot says it can run Test the bot (D124, #824, #864). GET /identity's
      features lists record_test beside dm, so Quest Board can tell a bot too old for
      Test the bot before a GM presses the button (additive; bot contract unchanged).
    • [webapp] Admin's recordings table says when each recording goes. "Kept 90
      days, as the campaign asks: until 23 November", or whose setting it is when
      that is the instance's or its cap. The date is the one the nightly clean-up
      acts on, taken from the same function; GET /api/admin/recordings gains
      audio_expires_at, audio_retention_mode, audio_retention_days,
      audio_retention_source and content_approved (#872).
    • [webapp][bot] Taking a handout back deletes its Discord post or DMs (D157,
      D238, #896). The handout path never recorded where it landed, so its own
      Undo neither told anyone nor deleted anything — this closes both gaps.
      • A handout reveal now records where it went: the campaign's channel post
        reuses lane 10's shelf_reveals.discord_message_id/discord_channel_id
        (POST /api/bot/reveals/{id}/message), and a chosen audience's DMs each
        get a row in a new shelf_reveal_messages table via a new, additive
        POST /api/bot/reveals/{id}/dm-message. Migration 8c9d0e1f2a3b, on top
        of lane 8's map. The reveal is now committed before the bot is asked to
        post it, so the bot's report of where it posted can never beat the row.
      • A handout's Undo and a shelf card's take-back are now one code path
        (shelf_service.take_back): stamp it, tell the campaign's other GMs
        (D238), commit, then publish reveal_taken_back carrying every recorded
        post or DM as extra.messages, so the bot deletes each — best-effort,
        silent on a message already gone. Lane 10's single-post fields stay in the
        event for an older bot. BOT_CONTRACT_VERSION stays 2.
      • Handout embeds are ported to faces.handout, the bot's one post shape
        (EXT-2), with a golden row in test_faces.py and a dev-preview specimen.
    • [webapp] A quest can be changed (D242, #925). A quest's page shows its status, main or side, who gave it, the reward, the sessions it started and ended in, and its objectives; a GM edits all of these, with an objective kept for the GMs never shown to players. Changing the status puts a line on the quest's timeline. A GM confirms a rumour as a quest, or marks it not canon, from the quest log.
    • [repo] A shared Claude Code setup for tiered delegation (#935). Specialist
      agents in .claude/agents/: qb-implementer and qb-frontend (Sonnet,
      medium effort), qb-preflight and qb-render-check (Sonnet, low), and
      qb-test-runner and qb-ci-triage (Haiku, low). Skills: /qb-test,
      /qb-ci-log, /qb-render, /qb-preflight and /qb-deploy-dev. Workflows:
      /qb-verify, /qb-build-slice and /qb-review. The local CI recipes now
      build test images from the Dockerfile's backend-dev and bot-dev targets,
      so they can no longer go stale.
    • [repo] The dev demo seed writes a GM copy of The Amberwake Accord. A fifth
      campaign, "The Amberwake Accord (GM copy)", mirrors the original row for row
      under its own ids, with the seats swapped: the owner's account owns and runs
      it and is credited with everything Dana did as GM, and Dana plays the
      owner's character. Every GM-only surface can now be reviewed on Amberwake's
      data. Its GM's bell also has a session short of players and a recording
      about to be deleted. The same builder writes both (engine.Variant), and
      --reset rebuilds the copy with the rest (#951).
    • [webapp] A Test-the-bot run's AI usage now counts in the campaign's usage and credits like any other run, marked as a test (D227). session_usage.session_id is nullable; a test row carries recording_test_id and purpose="test" instead, and Admin → AI usage & speed shows it as "Test the bot".
    • [webapp] Settings → Discord chooses its server, channel and voice channel from
      pickers, once Discord is linked (D226, D159).
      The bot answers two new internal
      routes — the Discord servers it shares with a given user, and the text/voice
      channels it can see in one of them — and the backend asks them only for the
      signed-in GM's own verified Discord link, never a Discord id from the client. A
      GM with no verified link, whose bot can't be reached, or who just prefers to, keeps
      typing IDs, exactly as before.
    • [bot] GET /discord/guilds and GET /discord/channels, bot-auth and additive
      (BOT_CONTRACT_VERSION stays 2): list a user's shared servers, and the channels the
      bot can see in one of them. Both re-check that the given user is actually a member
      of the guild in question before answering.

    Changed

    • [webapp] A wiki page in the bin now travels in a campaign's export, and
      an import puts it straight into the bin (#1009).
      Since #990 a binned page
      was left out of the file, so moving a campaign ended the days a GM has to
      restore it (30 unless the instance says otherwise). It is now carried in
      data.json (a new wiki_bin section; export schema 16) with the time it
      was binned, and an import writes it already binned: for the GMs only until
      one restores it, never live for a moment, and with the days it had left
      rather than a fresh count. What stood
      beside it comes back as it stood (its aliases, owners, relationships, name
      targets, picture, shelf cards and prep items, the thread about it), so
      Restore brings back what it would have in the old campaign. What was shown
      at the table stays shown until a GM takes it back, as it does where it came
      from: a card shown from the page, and the page's own reveal when it was
      shown in a session, are still listed under that session for the people they
      were shown to, by the title they were shown with; a player's handouts list
      leaves the page out until it is restored. A binned page is in nothing a person reads:
      no file under wiki/, no count in README.md, its picture under
      media/bin/lore/. A page already past the bin's days when the file is
      imported is deleted by the next daily purge
      , as it would have been where
      it came from, so restore it as soon as you have imported. Every other bin still
      stays behind, and an archive from before this release imports as it did. A
      Quest Board older than this one refuses a schema 16 file by its version;
      this one now says "made by a newer Quest Board … update, then import it
      again" when it meets a file newer than it reads. A fact or a timeline row
      that names a page further down the file now keeps its link on import, where
      it used to lose it. See docs/API.md.
    • [webapp] Shortening how long a campaign keeps transcripts is an ordinary
      confirmation now (#1010).
      It was the serious kind, with a red button, from
      when the older transcripts were destroyed that night. Since #680 they go to a
      bin and can be restored, so the question keeps its words and its offer to
      export the campaign first, and loses the red.
    • [webapp] The footer is at the bottom of the page, not a strip pinned under it.
      The version and the project, Help, Accessibility, Privacy and Licences
      links used to sit under the page's scroll area at every width, taking two
      lines of a phone's screen for good and cutting off the end of long lists.
      They are now the last thing in the page itself, at the window's bottom on a
      short page and reached by scrolling on a long one, beside the Contents
      rather than under it (#1020).
    • [webapp] The party page shows XP and gold as their figures, with a small
      History beside each.
      History opens every entry, newest first, in a dialog
      ("XP history" / "Gold history"), with the same Remove and Undo; the page no
      longer lists them all (#1020).
    • [webapp] The "Quest Board" wordmark in the app bar is now a link to Home.
      Inside a campaign it was only a picture of the name; it now takes you back
      out to Home from every page of the signed-in app, and is named "Quest Board,
      home" for a screen reader (#1020).
    • [repo] The Claude Code setup is now a thin layer over a shared, user-level set (#1037).
      Quest Board's test recipes, preflight checks, release rules, deploy facts and UI
      settings moved into a ## Project profile section of CLAUDE.md. /qb-test,
      /qb-ci-log, /qb-preflight, /qb-deploy-dev and /qb-render keep their names
      but delegate to the global skills and agents, and /qb-verify, /qb-review and
      /qb-build-slice delegate to the global workflows. The qb-test-runner,
      qb-ci-triage, qb-preflight and qb-render-check agents are gone, replaced by
      the global test-runner, ci-triage, preflight and render-check. The pull
      request template now asks for a changelog.d/ fragment instead of an edit to
      CHANGELOG.md.
    • [repo] Prereleases are cut for the dev server, and the Quest Board hosts
      run the published images.
      A vX.Y.Z-rcN tag now publishes release notes
      rendered from the pending changelog fragments (changelog.py render, which
      consumes nothing), and its images are :X.Y.Z-rcN only. A committed
      docker-compose.images.yml pins the six app services to the release's images
      and removes their build:; its tags must equal APP_VERSION, which
      check_version_sync.py now enforces. A host that adopts it sets
      COMPOSE_FILE=docker-compose.yml:docker-compose.images.yml in its .env and
      updates by checking out a release tag. A self-hoster who builds from source
      sets nothing and is unaffected. docs/OPERATIONS.md covers cutting a
      prerelease, the image update, rollback and production updates (#1043).
    • [webapp] Cutting a recording into speech spans no longer starts ffmpeg
      once per span (#684).
      With silence trimming on — and always on a managed
      provider with a per-request size limit — every speaker's track is cut into
      its speech spans before transcription, and each span was its own ffmpeg
      process: around 1,300 of them for a three-hour, five-speaker session, most
      of whose time was process start-up. The spans of a track are now cut
      twenty-five to a process. The files are byte for byte what they were, so
      transcripts, timestamps and the counts on a summarisation run are unchanged;
      on the 30-minute test scene the cutting went from 208 processes and about 17
      seconds to 11 and about 2. If a batch fails, its spans are cut one at a time
      exactly as before and the worker log says so once
      (… in one ffmpeg run failed … cutting them one at a time instead). ffmpeg
      briefly uses a little more memory for a batch than for a single cut —
      roughly 65 to 80 MB against 56 MB; the batch size was chosen to keep that
      small in a worker limited to 1 GB. Nothing to configure.
    • [repo] The synthetic-session guard tests take about half as long (#684).
      Besides the batched cuts above, the guards no longer re-run silencedetect
      over a track ffmpeg has already scanned unchanged (test-only; a rewritten or
      copied track is always scanned again), and a new guard compares the batched
      cuts of the scene's tracks with single cuts, byte for byte, in the CI job
      that is not allowed to skip.
    • [webapp] Anyone who may edit a wiki page directly can upload or remove its
      picture
      (D234, #734). It follows the campaign's Who may edit: a player's own
      pages by default, any page they can see under "players anything", none under
      "suggest" (D233). Setting a picture from a web address stays the GMs', since it
      makes the server fetch something.
    • [webapp] Admin's Recording & retention, Backup, Import and Audit log are
      rebuilt on their own pages (lane 9 page 4b, #735).
      Recording & retention
      gains the default/cap split (D24) with IS-311's confirm before a tighter
      cap catches a campaign's sessions, and says D179's retention promises
      correctly. Backup queues without asking first (D12), polls while a run is
      in progress, and no longer claims uploaded images are left out, since
      D143 already put them in. Import a campaign now shares its form with
      Home's New campaign → Import a file. The audit log filters by name
      rather than id, groups "What happened" into categories, and reads its
      detail as labelled lines with the record as stored one click further.
    • [webapp] Admin's AI models, AI usage & speed, Discord, Email and
      Instance health are rebuilt on their own pages (lane 9 page 4c, #735).

      The old 49-field Bot Settings tab splits into AI models (the capability
      panel, the transcriber and summariser, the summary and wiki modes, and
      who is using their own) and Discord (the bot's address and its key,
      with IS-394's identity check replacing the token field per D139).
      AI usage & speed gets an address of its own for the hardware preflight
      and the usage report. Email's test now sends what is typed, saved or
      not, and answers with the SMTP server's own reply instead of queuing.
      Instance health has a screen for the first time: the AI queue's
      free/busy/stuck verdict, polled every 10 seconds, and which secrets
      this server can read. Also fixes a date column (Joined, Last here,
      Started, When) wrapping mid-date on Users, Campaigns and Audit at 390px.
    • [webapp] Admin's Content packs, Content notices and Plans & entitlements
      are rebuilt, and the legacy Admin page is gone (lane 9 page 4d, #735).

      Content packs takes lane 3's kit pieces rather than the old Tailwind
      component, and adds starter packs and an instance pack's own default
      ("Every member", AD-120). Content notices gets a screen for the first
      time: where a rightsholder writes, filing and filtering notices, and
      acting on one (turning a pack off, or removing it or just the records
      named, all or nothing). Plans & entitlements shows the four prices
      Plan & billing already reads, hosted servers only. pages/Admin.jsx
      and its last two orphaned panels (AdminAiUsage, AdminHardwareProfile)
      are deleted. Also fixes two dialogs (making an administrator, handing
      the server or a campaign on) that drew no buttons at all, and a narrow
      screen squeezing a table's columns instead of letting it scroll.
    • [bot] Commands, and a private way to raise a concern (lane 10, EXT-1, #736).
      /recap now also takes a session's number (/recap 13), resolved to the right
      campaign in a server that binds more than one (O9); /next, /quests, /history
      and /ask resolve the same way, and every one of them offers a campaign: picker
      when it needs to ask. /history and /ask now answer privately, /history lists
      numbers instead of session IDs, and /ask names the sessions its answer was drawn
      from — worked out by Quest Board, never the model (O16). /next shows RSVP counts.
      /note and /help's words follow D97/D106. A new /concern opens a private
      Discord form, in a server or a DM, that reaches only the campaign's GMs through the
      same anonymous queue as the web's Table rules & safety flag (O13, D160) — nothing it
      carries is ever echoed into a channel, logged, or stored with a sender. All
      additive: BOT_CONTRACT_VERSION stays at 2.
    • [webapp] The server side of the above (#736). GET /api/bot/guilds/{id}/sessions/by-number/{n} resolves a session by number within a
      guild's campaign; channel_id/campaign_id are new optional parameters on
      /next-session, /quests, /sessions/list and /sessions/ask for the same
      resolution, returning 409 with the campaign names when a server holds more than one
      and neither narrows it. /sessions/ask's response gains sources. A new POST /api/bot/safety/submissions is /concern's door into the existing, sender-less
      safety queue, kept out of the access log the same way the web route is. No
      migration.
    • [bot] Every Discord post has one shape (lane 10, EXT-2, #736). A coloured edge
      for its kind (six tones that read on Discord's light and dark themes), a title in words
      with the kind first and the session by number ("On: Session 14", "Cancelled: Session
      14", "Recording saved · Session 14"), the facts as fields — When, Where (the
      campaign's voice channel, D159) and Who's in — one link back to Quest Board and
      none on a server with no public address, and Discord timestamps throughout. All built
      in one place, questboard_bot/faces.py. A time added to an open vote is now also
      posted on its own ("A time was added · Session 14"); a cancellation says who and why
      (D101); the approved summary is the only summary post and says who approved it (D96);
      no post promises a summary "when it is ready" any more, and a session_summarised
      event still queued from before D96 is dropped rather than posted. The "Recording
      stopped" line is gone: the table hears once, after the handoff. The bot's status reads
      "Recording · /help" while any recording runs (D158). tests/test_faces.py pins every
      face, and python -m questboard_bot.preview --channel <id> --yes posts every one,
      from invented examples, into a channel of the developer's own test server (D164, O17).
    • [webapp] Bot events carry what the posts need (lane 10, #736). Every session event
      adds number and voice_channel_id; the poll adds closes_at, the reminder rsvp,
      the cancellation cancelled_by, the played post duration_s, the approval
      approved_by, and a slate change says whether a time was added and by whom. The
      bot's /api/bot/sessions/{id}/timeslots adds number. All additive; the bot contract
      stays at 2.
    • [bot] Reactions and taking things back (lane 10, EXT-3, #736). Reacting to a
      confirmed session gains ❔ ("I might make it", D50) alongside ✅ and ❌, with mutual
      exclusion between the three enforced on the message (Manage Messages permitting); the
      confirmed post's footer says so. Voting on a proposed session now names the voter's
      whole slate in the acknowledgement ("A and C work for you"), not only the option that
      just changed. Taking a revealed card back down in Quest Board (O10) now deletes the
      bot's own Discord post too, and tells the campaign's other GMs (reveal_taken_back, a
      new bell kind). All additive: BOT_CONTRACT_VERSION stays at 2.
    • [webapp] The reveal message, and its removal (#736). POST /api/bot/reveals/{id}/message records where a shelf_reveal embed landed
      (shelf_reveals.discord_message_id/discord_channel_id, migration
      10b2c3d4e5f6); revoke_reveal now publishes reveal_taken_back for the bot to
      delete it, and notifies the campaign's GMs other than the one who took it back.
      BotAttendanceRequest.attended is now optional alongside a new rsvp field
      (yes/maybe/no); the vote endpoint's reply carries the voter's your_answers.
    • [webapp] The D&D 5e sheet names what it adds up, as drawn. Each number's
      working now reads "Chain mail 16 + Shield 2", "10 + 2 × (d10 → 8, 7) +
      Con 2 × 3", "Str 3 + Prof 2" and "2 + ⌊(3 − 1) / 4⌋" instead of generic
      labels, and penalties say their level ("Exhaustion 2") or their cause
      ("Chain mail, needs Str 13") (#737).
    • [webapp] Existing stat blocks become blocks on the new sheets, automatically.
      On upgrade, every person's and creature's stat block turns into a creature
      sheet for its game — Pathfinder 2e, D&D 5e, a game added by a content pack,
      or the generic stat block when its game is gone — keeping who could see it:
      a block everyone could read stays readable by everyone at the table, and a
      GM-only or player-private one stays with the GMs. Nothing is lost: the old
      block is kept with the sheet, a value that doesn't fit the new sheet is kept
      aside rather than dropped, and the page's block is rebuilt the first time
      it's opened. The stat block editor on a page, the importer, "Stat block" and
      "Convert stats" all work on the sheet now; a drafted stat block fills the
      new block's fields and, once approved, lands on the page's sheet instead of
      in its text. Administrators can run python -m app.sheets.rebuild_projections
      (with --dry-run to see what it would do); the worker also does it once
      when it starts (#737).
    • [docs] The lane 9 drawing gains Actions and Rest… for the sheet build (#737).
      Notes & actions lists a character's actions with their cost and full text, and
      the sheet header gains Rest… (D209); the drawing also drops its explanatory
      lines, the recap promise and the Foundry export (D208, D210, D200). The
      catalogue gains IS-459 to IS-462 and the survey is committed as
      docs/design/lane11-build-contract.md.
    • [webapp] The old stat block editor is gone; stat blocks are edited on the sheet.
      On an NPC's or a creature's page, the editor now shows "Open the sheet" when
      the page has one and "Make a sheet" when it does not, in place of the form of
      fields. Saving a page with a stats value is refused with "Stats are edited
      on the sheet." Pages still show their stat block, and importing a campaign
      still brings stat blocks in (#737).
    • [webapp] Pathfinder 2e sheets read the way the game prints them. The
      sums under a number now name the attribute they use ("Dex 4", "Wis 4") for
      class DC, spell DC and attack, and Strikes; Hit Points read "Ancestry 8 +
      6 levels 60 (Class 8 + Con 2 each)"; and a condition that has a value says
      it ("Frightened 1", "Stupefied 2", "Drained 1"). The numbers themselves are
      unchanged (#737).
    • [webapp] Admin follow-ups and the demo seed's own surfaces (lane 9 PR 6, #879).
      GET /admin/settings/bot/identity passes through the bot's own reason for
      a disabled Discord connection, so the Discord section says "Discord
      refused the token" separately from "No Discord token set" instead of one
      generic message. Instance health's "Campaigns' own keys (N)" line is real:
      the secrets audit now counts every campaign's own transcriber/summariser
      key and how many this host's SECRET_KEY can no longer read. AdminAi
      and AdminRecording regain the test coverage the legacy Admin page's
      deletion (#735) didn't carry over — gap severity ordering, a provider
      guessed from the address, the retention form's defaults and caps together,
      and the wiki pipeline's mode change and error states.
    • [repo] The demo seed gains two thin campaigns purely for Admin's own
      pages (#879, D145).
      The Salt Crown (archived) and Brinewatch (a
      one-shot, binned), both owned and GM'd by an invented person who has not
      signed in since — a stale owner, a failed recording, a transcript kept
      "for good" for the retention cap-confirm to name, a second campaign pack
      disabled after a content notice, and nine more audit-log events. Nobody
      plays these two tables; see webapp/backend/demo/README.md for why they
      stay that thin.
    • [webapp] The email answer page now says when a session has started or been cancelled, and links to it; the Notifications page says which of Discord's two refusals stopped your DMs.
      Answering a started or cancelled session from an email is refused (409) instead of reading as an expired link.
    • [webapp] Awarding XP is the GMs' alone (D241). The campaign's "players can add loot" setting still lets players add items and gold, but no longer XP; removing or restoring an award is the GMs' too.
    • [repo] The backend test suite is much faster. The local-Whisper /health probe is stubbed by default (tests that exercise it opt in with @pytest.mark.real_asr_probe), removing the ~8 s DNS-plus-timeout wait from about thirty recording-pipeline tests; a test network guard now fails any test that resolves an unmocked hostname (opt out with @pytest.mark.allow_network); and the synthetic-session harness (@pytest.mark.synthetic) is deselected from the backend CI job's pytest -m "not synthetic" since its own job already runs it. Full run: ~1,061 s before, ~536 s after (about 9 min).
    • [repo] CI's two slowest jobs now run in parallel pieces (#939).
      • Vitest was running on one worker. CI jobs get 2 CPUs, and vitest's default is CPUs − 1, so the whole suite took 1,603 s. The frontend job is now two shards, each with --maxWorkers=2. Lint, the build and the audit run once, in shard 1.
      • The journeys were running one project after another. They're now a matrix of three jobs (desktop, phone, keyboard), each with its own stack. The one-worker constraint (per-IP login rate limit, one shared seeded database) is per stack, so no spec changes.
      • Three prep-sheet test waits no longer fail under load. Their explicit timeout: 4000 was shorter than the suite-wide 5 s asyncUtilTimeout. The two that wait on the 1 s autosave now wait 8 s.
    • [webapp] Session emails no longer repeat "Quest Board" as a heading under the brand line; they open with the news itself, as drawn (EX-040).
    • [webapp] The design system reference page, /design, now ships in production
      builds, for server administrators only.
      Anyone else, signed in or not, gets
      the same "not found" page as any unknown address. Development builds still
      open it to everyone.
    • [webapp] Cards on the GM's table board are packed, and a new one lands beside
      the last card on screen.
      A card nobody has moved used to take a slot by its
      place in the whole shelf, so a shelf whose cards had mostly been placed by hand
      drew a new one far down the board with empty slots above it. Unplaced cards now
      fill the first free spots around the ones the GM placed (which never move), and
      a note or a card added from Find goes next to the last card in view — to its
      right, else under it — never on top of another card.
    • [repo] Changelog entries are now fragments in changelog.d/, gathered
      into CHANGELOG.md at release.
      Every PR used to add its bullet
      directly at the top of [Unreleased]'s section lists, which clashed
      between PRs merged close together. Now each PR adds a small file under
      changelog.d/ instead; make changelog (scripts/changelog.py assemble)
      gathers them at release time, and make changelog-check (also run by CI)
      validates them. See changelog.d/README.md.

    Fixed

    • [webapp] Level up no longer offers another class's feat as if it could be taken. At a class feat step a Fighter was listed a rogue's feat, because feats carry their class as a trait rather than a prerequisite. It is shown disabled with the reason ("A rogue feat."), the way a feat with an unmet prerequisite is, and applying the level with it is refused. A feat can still be typed by name (D261, #1008).
    • [webapp] A temporary hit point figure typed straight after a sheet opens is no longer put back to the old number. The box reset itself to the server's figure on mount, which could run after an early edit (#1017).
    • [webapp] An article's text no longer sits against the rule under its
      tabs.
      The Read tab now leaves the same gap below the rule as the other
      pages, for a player's view and a GM's, at every width (#1020).
    • [webapp] On /design, pressing anything no longer blanks the open panels.
      The bell, menus, account menu and search drawn open closed on a press
      anywhere else on the page, or on Escape, and nothing brought them back; the
      ones drawn open now stay open (#1020).
    • [webapp] A page shown to you as a handout no longer logs a 404 for its map
      markers.
      "On the map" asked about a page the reader may open through a
      reveal, and the route only knew the page's own audience; it now answers with
      the markers she can see, usually none (#1020).
    • [webapp] Opening a notification from the bell now marks it read. Following a
      row's link (a click, Enter, or a new tab) did not clear it in a browser,
      because the frame closed the panel before the row heard the click; the row
      now marks itself first. The tick beside a row still marks it without opening
      it (#1020).
    • [webapp] The page a player meets on a GM's prep address reads as
      English.
      It said prep "is where each session is got ready"; it now says
      "Prep is for the GMs of this campaign: it is where a GM gets each session
      ready." Planning's own version of the line is reworded the same way
      (#1020).
    • [webapp] A scene can be deleted from the prep sheet, and a card's "File it
      in" menu there can be read and used.
      The sheet's Shelf section could make
      and rename a scene but not delete one; each scene now has Delete beside
      Rename, the same delete Table mode's Scenes panel has: at once, with an Undo,
      the scene's cards staying on the shelf under "No scene yet" until the undo
      files them back. The menu on a shelf card was drawn inside its list and cut
      off at the list's edge, so only its heading and "No scene" could be seen; it
      now hangs clear of the list, and it offers every scene with the card's own
      marked, where it used to leave that one out (#1020).
    • [webapp] A search puts the thing you named first. Searching the wiki for
      "Odile" listed Bastian Crowe, whose page mentions her, above Odile Marsh:
      a wiki search had no ranking at all and answered in the wiki's browsing
      order (kind, then title), where a character comes before an NPC. Every
      search now follows one rule, written once on the server
      (app/services/search_rank.py): the name that is the query, then a name
      that starts with it, then a name with a word that starts with it, then the
      same for its aliases, then everything that only mentions it — each of those
      in the order the search had before. Case, accents and extra spaces don't
      count. It applies to the app bar's search, the wiki's own, Table mode's
      Find, the prep sheet's Rules & bestiary and Wiki drawers and its article
      pickers, and the rules search, and it is applied before any limit, so the
      exact match is never the row a cap drops. A player's results are ranked by
      the names a player knows: an unidentified item by what the party calls it,
      and a page they can't see is not there to be ranked. A rules record is also
      now found by the start of any word in its name ("stalk" finds Reed Stalker).
    • [webapp] The wiki front page's results open under its search box. They
      hung from the far right of the page's main column, away from what was
      typed: the box is the app bar's search component, whose results hang from
      the right edge of a box that, on this page, was as wide as the column. The
      box is now up to 32rem wide and the results start at its left edge at the
      same width, on a phone too.
    • [webapp] The last results in a prep-sheet drawer are no longer cut off by
      the footer.
      The tool margin beside the sheet was sized from the window
      (100dvh - 6rem), but it sticks inside the page's scroller, which is the
      window less the app bar, any banner and the footer — so its last 36px or
      more sat behind the footer, where neither its own scroll nor the page's
      could reach. It is now sized from the scroller itself, measured, so it holds
      whatever the bar and the footer do.
    • [webapp] A result found by its own name no longer says the name twice.
      "Odile Marsh" sat over "npc · Odile Marsh". The line under it now says how
      the page starts — in the text that reader may read — or, for a session, how
      its summary starts; with nothing to say it is just the kind or the date.
    • [webapp] Table mode's Find no longer scrolls sideways. A row was as wide
      as its whole snippet (778px in a 590px list), so the list scrolled across
      and the snippet was cut at the list's edge. Rows now fit the list, the title
      keeps its room and the snippet ends in an ellipsis.
    • [webapp] In the prep sheet's lookup drawers, "In the sheet" stays on one
      line on a phone, and a preview that opens is brought into view
      so its
      Insert is not below the fold of the Tools sheet.
    • [webapp] A GM can find the sweep, and the table, from a session that has
      been played.
      "Sweep the shelf" was only at the foot of the Review tab's
      checklist, and nothing on a played session's page led back to its table. A
      GM now has both under the tabs on Read as well as on Review: Sweep the
      shelf
      while any card from the session is still on its shelf, and Open
      the table
      always — the evening's board, its scenes and what was shown are
      still there. In Table mode the sweep's button is called Sweep the shelf…
      too, in the bar and at the foot of the phone view, where it used to say
      "End of session…". Closing a sweep opened from the session page takes
      ?sweep=1 off the address, so reloading or going Back does not open it
      again (#1020).
    • [webapp] Showing something to the table from a GM's phone no longer turns
      large.
      A card put on stage on a phone was set in the desktop stage's
      display type — everything a size up, its first line at heading size — when
      only the title had been brought down for a phone; the phone's stage is now at
      the phone's size, as drawn. "Show the table" opened a dialog whose field was
      13px, which makes iOS Safari zoom the whole page in as the field takes focus;
      on a phone the field is 16px. A long session or card name wraps instead of
      pushing the page sideways, the bar's clock is the phone's size, and every
      button on the phone view and in its dialogs is at least 44px, including
      Leave, the stage's ×, ‹ and ›, and the sweep's rows (#1020).
    • [webapp] A thread marks the posts that were unread when you opened it.
      Each carries a dot and the word "New" (not colour alone), with a "New since
      you were last here" line above the first; they stay marked for the visit
      and are read on the next. The thread's read answer gains is_new on each
      post and read_through on the thread, both additive (#1020).
    • [webapp] The wiki's front page lists Quests under Browse by group.
      Every entry type had a row there but a quest's, which was only reachable
      from the Contents' quest log; the row opens it and counts the quest pages
      the reader can see (#1020).
    • [webapp] The Contents' wiki chapters now include Events and Lore.
      They were reachable only from the front page's Browse by group, so the
      sidebar and the phone's Contents looked as if categories were missing; both
      now list all six groups in Browse by group's order (#1020).
    • [webapp] Back and forward return to where you were on pages that load their content after the page opens. A session's Review tab and Admin → Audit log came back short, or at the top, because the position was set before the page was tall enough; it is now kept trying until the page can reach it, and stops the moment you scroll yourself (#1023).
    • [webapp] The notifications panel no longer scrolls inside itself. Its hidden "unread" labels were not contained by the list, which made the panel taller than its box; the heading could end up half cut off at the top (#1023).
    • [webapp] The wiki editor's Save row stays at the bottom of the window all the way down the page. It used to leave the screen once the reader scrolled past it to the stat block, picture and relationships; it is now the last thing in the editor and settles at the end of the page, above the footer (#1023).
    • [webapp] The page no longer lets the whole window scroll under the app frame, and the bell and Contents buttons are 44 pixels wide on a phone. A visually hidden label far down a long page could make the window itself scrollable; the page's own scroller now contains it. The footer's links no longer leave a dangling "·" at the end of a wrapped line (#1023).
    • [webapp] A menu no longer hangs off the side of a phone screen. A menu
      hangs from one edge of the button that opens it, which is right only while
      that button sits near that edge of the page. A wiki article's "See it as…"
      wraps to the left on a phone, and its menu ran 30px off the screen with its
      names cut short. Every menu now checks where it has opened and moves back
      inside the page, and is never wider than the screen (#1029).
    • [webapp] Choosing a feat or a class from the loaded content no longer
      loses the list while it searches.
      Level up's feat picker and Start a
      character's "Choose from the loaded content" emptied their list for the
      length of every search, said "Nothing matches" until the answer came, and
      took a row out from under whoever had just reached it with the keyboard. The
      results on screen now stay until the new ones arrive (#1030).
    • [webapp] Coming back to a discussion just as it finishes loading now
      refreshes it.
      A thread, a list of threads and a Talk tab's count read
      again when the window regains focus. A focus arriving in the instant the
      page finished loading was dropped, because the page had not yet been told it
      was loaded; the next one worked. It also made one test fail now and then on a
      busy build machine (#1036).
    • [webapp] A flaky Preview test waits for the scroll it asserts (#1039).
      The test for bringing the preview panel into view a second time checked the
      call on the line after the entry's text appeared, which on a busy CI runner
      is before the effect that makes the call has run. Test only.
    • [webapp] A card's notes for tonight are no longer lost when the card
      closes mid-sentence.
      The notes save a second and a half after you stop
      typing. Closing the opened card, or the stage moving to another card, inside
      that moment threw away what had just been typed. A save still waiting is now
      sent as the field goes (#536).
    • [webapp] A character can no longer be given a name that another character
      is "heard as".
      Listing "Wyatt" as a misspelling of Idani and then naming a
      second character Wyatt was accepted, and from then on that misspelling
      silently stopped being corrected in transcripts, with nothing to say why.
      Creating or renaming a character, finishing a sheet, or assigning an imported
      member to a name like that is now refused, saying whose misspelling it is. A
      campaign already in that state is unaffected: no transcript was ever
      rewritten wrongly, and its characters can still be edited (#634).
    • [webapp] Shortening how long transcripts are kept no longer destroys them
      on the next nightly run (#680).
      A transcript past its campaign's window
      now goes to a bin first, as a recording does, and is deleted for good only
      once the bin's grace has passed — the same number of days the bin keeps
      audio, never fewer than seven. While it is in the bin nobody can read or
      search it, it is in no export and is sent to no model; the session's summary
      and highlights are untouched. The campaign's GMs are told three days before
      it is deleted (one notification when a session's recording and transcript
      are due together; nothing new is posted to Discord), and a GM can restore it
      from the session's Review tab. Lengthening the window again lets every
      transcript it no longer reaches back out of the bin on the next run, so a
      window shortened by mistake is undone by putting it back. Erasing a member
      still reaches a transcript in the bin. Settings → Recording, retention & AI
      now says where the transcripts go and for how long, instead of "There is no
      bin behind it". Upgrading runs one migration
      (sessions.transcript_trashed_at, sessions.transcript_deletion_warned_at);
      a transcript the old sweep already deleted is not brought back by it. See
      docs/OPERATIONS.md → "How retention removes recordings and transcripts".
    • [webapp] A player's Handouts page no longer breaks when a handout was shown to her with no file of its own; it is listed as a page.
    • [webapp] The party page's character links are underlined, not told apart from the text around them by colour alone (WCAG 1.4.1).
    • [webapp] Maps are cut into tiles. The worker mounted the media volume
      read-only, so every map's tiling failed on every install. It is writable
      now; a customised docker-compose.override.yml that redefines the worker's
      volumes must drop :ro from media:/app/media. A failed map now says why in
      words rather than showing the server's error and file paths.
    • [webapp] A party's trail stays in its own campaign. Its routes did not
      check the party's campaign, so a member of one campaign could read another
      campaign's trail by its id, and a GM could add stops to it. A stop on a map a
      player can't see no longer tells her where it is.
    • [webapp] Creating an atlas, a map or a marker no longer fails with a
      server error.
    • [webapp] The first person to sign in to a new server is its owner as well as its administrator, so Admin's "Run by" line and hand-the-server-on work from the first day. The J1 journey now walks Admin's leftovers, Backup and the not-an-administrator stop in all three projects.
    • [webapp] Fixed the Notifications grid's narrow-screen note always saying "always comes to the bell" even for the star-and-wish row, which shows on Home instead — it now names any such exception.
    • [webapp] A mail app's own Unsubscribe button now stops the emails it is on.
      The List-Unsubscribe header named the "Stop these emails" page, which answers
      the one-click POST (RFC 8058) with a 405, so pressing Unsubscribe in Gmail or
      Apple Mail stopped nothing. It now names POST /api/email/stop/one-click; opening
      that address instead shows the page, which asks first.
    • [webapp] A sheet no longer says "Someone else changed this sheet" about
      your own save (#737).
      Leaving a sheet, or a character being started, in
      the moment its changes were being saved kept a copy of them on the device;
      coming back, that copy was sent again and refused as a change made
      elsewhere. What the server already holds is now let go of, and only what is
      really still unsaved is put back.
    • [webapp] A character who has left the party stops collecting its XP
      (#737).
      A retired character's sheet went on counting every award to the
      party, and said a level was waiting. Their own awards are still theirs.
    • [webapp] Each "Choose from the loaded content" on Start a character says
      what it chooses (#737).
      The Ancestry step had three buttons with one
      name; a screen reader now hears which is the ancestry's, the heritage's
      and the ancestry feat's.
    • [webapp] A failed recording's retention clock no longer restarts when its
      session is written to (#798).
      A session whose processing only ever failed
      counted its audio retention from updated_at, which moves on any write to
      the row, so erasing a member from it (the erasure note) or editing its title
      kept the audio for another full window. It now counts from the new
      write-once sessions.audio_received_at, stamped when the bot's audio is
      first accepted. Migration a798c0d1e2f3 adds the column and backfills
      existing failed runs from updated_at, so nothing is caught earlier or
      later on upgrade day. The campaign export and import carry the new field.
    • [webapp] A campaign's export now carries its prep sheets, story arcs and
      the shelf, and an import puts them back (#830).
      A GM who exported a
      campaign to move it, or to keep a copy, lost their prep and their table
      setup. The file (schema 15) now holds each session's sheet, beats, plan and
      items (For later included), the arcs, and the shelf's scenes, pins, cards and
      reveals, each section marked as the GMs'. A pin rides the later sheets and
      shelves as it did, and a card taken off a shelf stays off it. A reveal comes
      back no wider than it was shown (one for named people becomes the GMs'), and
      one taken back comes back taken back. Discord message ids and Workbench runs
      do not travel: a generated or a rules card reads from what it kept. Older
      files import as before. See docs/API.md.
    • [webapp] A paused or unreachable Redis no longer hangs the request that
      touches it, and notify() stops trying recipients once the broker is down.

      Every async and sync Redis client the app builds now has a 2 s connect timeout and a 10 s
      reply timeout (the Celery side already had its own), built in one place,
      app/redis_pool.py; the one client that waits on pub/sub (the live
      recording-status stream) keeps no read timeout. A dead broker used to cost one
      publish timeout per recipient; the first connection failure now skips the
      rest of that batch (the in-app notifications are still written). (#841)
    • [webapp] Notification emails are sent again (#865, #736). The delivery task
      read its SMTP settings on a second event loop inside its own, which cannot start,
      so every per-person notification email since #655 was logged as "Failed to load
      SMTP config" and dropped — including, once lane 10 moved them onto that path, a
      group without Discord's time votes and confirmations (D153). The task now reads
      the settings in its own session and sends off the loop.
    • [webapp] A phone's Contents no longer names Admin twice. With Home's
      new "This instance" group on screen (lane 9 page 4a), the account rows
      below it dropped their own Admin link rather than repeat the door under
      the same name.
    • [repo] Two backend tests stop leaking rows into later test files (#869).
      test_recap_email deletes the SMTP config row rather than nulling it, so it no
      longer breaks test_admin's test-email tests when it runs first, and
      test_backup_offsite cleans up the BackupLog rows its task tests commit.
    • [webapp] The Review checklist's "Recap email" row is truthful before approval (#871,
      D162). Since the recap now fires on approval rather than at processing time, the row had
      no record to read yet and showed a bare "No record". It now reads a server-computed
      forecast — "To four people, once you approve" when the campaign has recaps on and someone
      would get one, or "Skipped" with why (recaps off, or nobody on the attendance list has
      opted in) — the same check the send itself makes, made read-only.
    • [webapp] Admin → Campaigns lists the campaigns in the bin, dimmed, with the day
      each goes for good, as the drawing does.
      The page never asked the server for
      them. GET /api/admin/campaigns gains purge_at. Its Hand it on… dialog also
      hands on to the member it shows selected, where it sent an empty choice unless
      the list was touched first. A server with no campaigns says so (#872).
    • [webapp] Admin → Campaigns' "Keeps its own" column says what a campaign keeps
      ("Audio 90 days", "Transcripts for good") instead of always "Its own settings",
      and a failed recording's error no longer ends in ".”." (#872).
    • [webapp] Admin's Audit, Users and Campaigns tables scroll sideways on a phone
      instead of wrapping every cell over several lines.
      Each table has a width of
      its own, dates and short cells stay on one line, and the scrolling box is
      named and reachable by keyboard (#872).
    • The GM who confirms a session, books one directly or moves it is no longer sent a bell row and an email about it; the rest of the table still is (#887).
    • [webapp] A page's picture is as private as the page (#894). Pictures were stored publicly, so a GM-only page's picture could be opened by anyone with its address. They are stored privately now and served only to people who can read the page. A picture uploaded before this moves to the private store the first time it is shown, and its old address no longer works. The backend's own media mount (used where nginx isn't in front of it) no longer serves private files either.
    • [webapp] A player's wiki save can no longer rename an item that isn't
      identified yet (#912, IS-122). A player only ever reads the party's name
      for such an item, never its real title, so PATCH .../lore/{id} now keeps
      the real title silently if she sends back what she was shown, and refuses
      the save with a 403 if she sends anything else — a GM's rename still works.
    • [repo] Fixed every upload failing on a fresh install: the backend image now creates /app/media owned by its own user, so a new media volume is writable (#914).
    • Choosing people to show something to, then clicking the next control in the dialog (such as "Send it to them on Discord"), now does both; the first click is no longer lost (#918).
    • A session page no longer flips back to what it said before a GM's action (Withdraw, say) when a background refresh that left earlier lands late (#932).
    • A slow answer can no longer overwrite a newer one anywhere the app re-reads while it waits: the Review tab, the vote results fold, and the Try again buttons (#941).
    • [webapp] A raw DELETE FROM campaigns no longer fails when a quest
      objective was done in one of the campaign's sessions (#943).
      The objective
      was reached by a cascade through its quest and a SET NULL through the
      session in one statement; its quest foreign key is now deferred to commit.
      The demo seed's delete-order workaround for it is gone.
    • [webapp] The demo seed's played sessions load again (#978). The seed wrote each played session's characters into canonical_names, which holds the name generator's picks, so every one of them failed with "This session could not be loaded"; a test now builds every seeded session's page as its GM and as a player.
    • [webapp] One star, one wish asks about every campaign that played, at
      once.
      A player in two campaigns that both played last night was asked
      about one, and the other appeared only after answering and reloading,
      which read as the same question twice. Home now shows one card per
      campaign, each naming its campaign when there is more than one, and
      GET /api/me/star-wish returns prompts (with prompt kept as the
      first of them) (#980).
    • [webapp] Campaign Settings → Recording's "Test the bot" box has room inside
      its border.
      Its text and buttons ran right up against the edge; the box now
      has the same inner padding as the AI panel above it.
    • [webapp] The models that write about people are now told their pronouns, so a saved recap no longer has to guess. The session summary, its event log and key moments, the wiki suggestions, /ask, the wiki's draft tools and one-line page summaries, and the GM's prep tools each get the pronouns of the people they can name, and only of those who have set some: players' (from their account), their characters', and those on wiki pages the table can read. A player's and their character's are sent as two separately labelled lists and never stand in for each other, and the model is told to use a name or singular they for anyone not listed rather than guess. Nothing is copied: they are read each time a prompt is built, so clearing them, leaving a campaign or deleting an account takes them out of every later prompt. Pronouns are personal data and now go to the configured model along with the names and transcript that already did; /privacy and docs/OPERATIONS.md ("What the LLM is sent") say so. (#999, finishes #653)
    • [webapp] A tap on a phone no longer goes missing while the page is still
      scrolling.
      The page's scroller had scroll-behavior: smooth, which
      animated every scroll — the browser's own scroll to whatever took focus, a
      restored position, the scroll to a button about to be pressed — and a tap's
      click, which a phone sends a moment after the finger lifts, landed on
      whatever had slid under it by then. It was J5's intermittent phone-only
      failure (Strike, Reword and "Accurate" not taking). Scrolls are now
      immediate; a jump you ask for (a Contents entry, an event's "at 1:09:12")
      still glides, unless your system asks for reduced motion.
    • A scheduling test no longer fails on Tuesdays: it put a session past the 14-day RSVP horizon on that one weekday.
    • [webapp] The web fonts are back. No production build had shipped a
      font file since the design system moved to self-hosted fontsource faces, so
      every page rendered in the browser's fallback sans-serif: Tailwind's PostCSS
      pass lost the path Vite needed to find the font files, and Vite only warns
      about that. The faces are now imported from JavaScript, and Vite emits them
      with hashed names (none inlined, which the CSP would refuse). npm run build
      now fails if any url() in the built CSS points at a file that is not there,
      and nginx answers a missing /assets/ file with a 404 instead of the app's
      index.html (#891).
    • The wiki no longer names the demo campaign to everyone: its front page, a group and Recent changes said "the Amber Coast" and "Dana" in every campaign. They say "the GMs" now (D174).
    • A wiki change reads "Dana started it", not "Dana new page it", on the front page and in Recent changes.

    Security

    • [webapp] The add-member and attach-to-an-account search needs two letters
      before it finds anyone (#702).
      One letter, or an empty query, used to list a
      slice of every account on the instance whichever scope was set; it now answers
      nothing, and the page does not ask until two letters are typed. % and _
      in a search are plain characters rather than wildcards.
    • [webapp] An unidentified item's true name no longer reaches players
      outside the wiki (#877).
      A page with a name the party knows it by ("A
      tarnished silver ring") showed that name on every wiki read, but not
      everywhere a page is named. A player could read the real one in the
      page's History, in the bell line, email and Discord post for a handout
      shown to them, in a reveal's own title, in "another page is already
      called that" when they guessed it, in a campaign timeline row with no
      words of its own, in the quest log, the bot's /quests, the pre-session
      reminder's open threads and the session page's open loops, in "Add to a
      sheet", in the name a new character sheet starts with, in "… reached
      level 7" and in a rest's "… has no hit dice". The wiki's list was also
      sorted by the real name. Each now uses the name the reader knows; a GM
      still reads the real one in their own views, and words written once for
      everyone (a reveal, a Discord post, a milestone) carry the party's name.
      A new test lists every read of a title in the server and fails on one
      nobody has classified (tests/wiki/test_title_readers.py).
    • pypdf, which reads the page count of uploaded handout PDFs, is on 6.19.0 rather than 5.1.0, clearing the parser advisories pip-audit reports against it (#930).
    • [bot] pyjwt 2.13.0 → 2.14.0, clearing ten advisories (CVE-2026-101917, CVE-2026-102265 to 102274) against the version the bot pulled in through redis.
    • [frontend] Dev tooling: brace-expansion and fast-uri patch bumps, clearing four advisories (GHSA-q2hr-2g5m-vwhr, GHSA-qhr7-859c-m2p7, GHSA-6j4f-fj2g-mc7p, GHSA-hrr3-gc8f-f4qj) in build-time dependencies; nothing shipped to the browser changes.
    • [webapp] A wiki page in the bin is no longer in a campaign's export, so
      an import can no longer bring it back live (#990).
      The export wrote a
      binned page like any other, and the import has no way to put a page in the
      bin, so it came back as a live page with the audience it last had: a player
      of the new campaign could read what the GM had taken away. A soft-deleted
      row now stays behind, with everything in the file that hangs off it — the
      page's Markdown file and picture, its aliases, owners, name targets and
      relationships, any reveal of it, and the wiki cards, pins and prep items
      that carry a copy of it. A row that pointed at it keeps its own words and
      loses the link. The import also skips any row a file marks as deleted
      rather than importing it live. Nothing that is only ended is left out: a
      cancelled session, a hidden post, a reveal taken back and a card taken off
      a shelf travel as before. An archive made before this release may still
      contain binned pages, which import live — export the campaign again
      instead of importing an old file.
      See docs/API.md.
    • [webapp] The draft tools (rephrase, expand backstory, promote, stat block, merge) and the relationship-proposal prompt now show the model an unidentified item by the name the table knows it by, so a model can no longer write its true name into text meant for the table. What a GM reviews and what an approval writes back are unchanged, and an approved proposal never renames the page to its player title. A proposal about an unidentified item now finds its page by the table's name instead of arriving as a new page (two items sharing a player title are not guessed between). (#993, follows #877)
    • [bot] pyjwt 2.14.0 → 2.15.0, clearing CVE-2026-101918 against the version the bot pulls in through redis; and [webapp] serialize-javascript 7.1.1 → 7.1.2 in the frontend's lockfile (GHSA-gfhx-hw2g-v5hg), a build-time dependency that is not shipped (#995).
    Downloads
  • v4.4.1 a6abef1ca7

    v4.4.1
    All checks were successful
    CI / Bot/backend version sync (push) Successful in 1m0s
    Release / Create Forgejo release (push) Successful in 14s
    CI / Backend lint (ruff) (push) Successful in 1m20s
    CI / Docker image build (push) Successful in 34s
    Release / Build and push versioned images (push) Successful in 1m5s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 2m16s
    CI / Bot tests and audit (push) Successful in 3m34s
    CI / Frontend tests, audit, and build (push) Successful in 4m11s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 12m46s
    CI / Backend migration, tests, and audit (push) Successful in 14m39s
    Stable

    claude-bot released this 2026-09-12 03:27:06 +00:00 | 2304 commits to main since this release

    Fixed

    • [webapp] A session's quote board is no longer built from only part of the
      session
      (#613, #614). A long session is read by the AI in windows, and how
      much text fits in one window was estimated with a character count tuned for
      ordinary prose. A transcript is not ordinary prose — every line carries a
      timestamp, a speaker's name, and often half a sentence — so the estimate ran
      around 60% under, and a window could be refused by the AI server as too big
      to read. A refused window was then dropped with nothing in the log but the
      word RuntimeError. For the quote board that was worse than it sounds: a
      partial set of quotes replaced the previous complete one, so a board could
      end up holding only the last twenty minutes of an evening, with the good
      version already deleted. Now a refusal teaches Quest Board how far its
      estimate was off for that server, the window is split smaller and asked
      again once, and a quote board is replaced only when every window came back.
      The same recovery covers the two other places a long session is read in
      windows: the events behind a summary, and the scan that suggests wiki
      updates. Nothing needs configuring — the correction is measured from the
      refusal itself, is not written into your settings, and sessions that already
      summarised correctly send exactly the same windows they did before.
    Downloads
  • v4.4.0 aa2c965c0c

    v4.4.0
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 35s
    CI / Docker image build (push) Successful in 34s
    Release / Create Forgejo release (push) Successful in 32s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m0s
    CI / Bot/backend version sync (push) Successful in 1m10s
    Release / Build and push versioned images (push) Successful in 1m6s
    CI / Bot tests and audit (push) Successful in 1m56s
    CI / Frontend tests, audit, and build (push) Successful in 3m16s
    CI / Backend migration, tests, and audit (push) Successful in 7m54s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 17m35s
    Stable

    claude-bot released this 2026-09-12 00:01:27 +00:00 | 2308 commits to main since this release

    Added

    • [webapp] Wiki suggestions wait until you approve the summary, and your
      corrections clear them
      (#610). Suggested wiki updates used to be made once,
      automatically, the moment a session finished transcribing — before anyone had
      read a word of it. They are built from the summary and the transcript and
      never from the events, so nothing you struck, corrected or relabelled could
      reach them: a GM who misspoke "You're in Ironroot Hold" could correct the
      event and watch the wiki go on proposing Ironroot Hold as a place the party
      had arrived at. Now nothing is suggested until you press Approve summary,
      which is the moment you have decided the session's record is right; and any
      regenerate, restore or hand-edit throws away the suggestions you have not
      acted on yet, putting the button back to Suggest wiki updates. Wiki
      entries you already approved are never touched by that. If you want
      suggestions before approving, the button works exactly as it always did.

    • [webapp] Tell the summary what is story, and what it must not leave out
      (#607). Two more corrections in "Everything that happened", beside Strike and
      Correct. Mark as table note moves an event the model mistook for story —
      a rules explanation filed as a reveal — into the folded table notes, where it
      is never narrated; a table note that was really part of the story gets Mark
      as story
      , with the kind you pick (other, unless you choose). Emphasise
      marks an event the summary must include: it is written in on top of the
      events the summary is sampled from on a long session, never in place of one,
      and the writing step is told it may not be left out. As before, everything is
      held on the page — several corrections on one event if they go together, each
      undoable — until Regenerate summary. Afterwards the new summary's events
      show a star on what you emphasised and "was: reveal" on what you relabelled,
      and Un-emphasise takes a star off. Players see the result — a relabelled
      table note folded like any other, an ordinary row for one brought into the
      story — and never the markers.

    • [webapp] When the summary is wrong because the table said something wrong,
      you can fix the event and get a new summary
      (#424). Some mistakes are true
      to the transcript, so nothing can catch them: the GM says "You're in Ironroot
      Hold", meaning "you were", the event checks out because it was said, and every
      summary since puts the party in Ironroot Hold. Under the summary, in
      "Everything that happened", a GM can now Strike an event or Correct its
      wording, see the pending changes marked, undo any of them, and then
      Regenerate summary. Only the writing step runs again — no transcription,
      no re-reading of the session — so it takes about as long as the summary's
      last step did. The events the model found are kept exactly as it found them;
      the corrected set is recorded alongside them, with the original wording, so a
      corrected event shows "Corrected by GM" and what it used to say, and a struck
      one stays visible to you, struck through. Players see the corrected text and
      never see a struck event. Regenerating replaces the current summary, hand edits
      included, so the old one is kept and Restore the summary from before puts
      it back. An approved summary can be regenerated just as it can be edited, but
      the copy already posted to Discord is not changed — withdraw approval and
      approve again to repost. If the regenerate fails, the summary is left as it
      was, the reason is shown, and your corrections stay on screen to try again.

    • [webapp] A short summary you will actually read, and underneath it,
      everything that happened
      (#568). Long sessions were being summarised badly in
      two different directions, and both were the same argument: the summariser is
      asked for three to six paragraphs and told to represent every verified event,
      and on a real 59-event session the local model resolved that by quietly leaving
      twelve of them out, while the hosted models resolved it by writing ten thousand
      characters nobody skims. So the summary is now written from up to thirty-six
      events (three to seven paragraphs' worth; see #600), sampled across the whole
      evening rather than off the top of it, in the order they happened (see #589) —
      and every verified event, however many
      there are, is listed in full under it: "Everything that happened (59 events)",
      collapsed until you want it, then one timestamped line each saying when, who,
      and what. That list is built from the checked events themselves rather than
      written by a model, so nothing in it is a guess and nothing can be dropped from
      it, and it is there for your players too, exactly like the summary it sits
      under. Events the validator could not confirm are still yours alone, with the
      reason, where they always were. A session under thirty-six events reads exactly
      as it did before this release.

    • [webapp] You can tell Quest Board what the transcriber calls your
      characters, and it will stop calling them that
      (#579). Every character now
      has an "Also heard as" list — the names the transcript gets wrong for them —
      and after transcription each whole-word match is snapped back to the real
      name, possessives included ("Vic's" becomes "Viq's") and without touching a
      word that merely starts the same way ("Victor" is left alone). This is not
      another attempt to persuade the decoder: the campaign's names already go to
      Whisper as an initial prompt and, since #577, optionally as hotwords, and on
      the session that prompted this "Viq" still came back as Vic, Vick or Nick in
      all 22 mentions, because a speech model will not overturn a common English
      word the audio genuinely matches. So the correction is stated by you and
      applied deterministically, to every speaker's track — players say each other's
      characters' names constantly, and a line that says "Vic" cannot support a
      summary beat about Viq. The listed misspellings also go to the transcriber
      alongside the real names, which sounds backwards and is the point: a
      consistently wrong spelling is one that can be corrected, and three different
      wrong ones are not. Quest Board refuses an entry that is another character's
      name, and each run records what it corrected as name_snaps on the
      summarisation run, so a name that never needed fixing is an entry you can
      delete (migration 6b7c8d9e0f1a).

    • [webapp] The campaign's names can be sent to the transcriber as hotwords
      instead of an initial prompt
      (#577). The bundled WhisperX server takes the
      same list of proper nouns two ways — Whisper's initial_prompt, a sentence
      the decoder is told it has just transcribed, and faster-whisper's hotwords,
      the dedicated bias for exactly this job — and they share one slot in the
      prompt, so exactly one is ever sent. Which works better is a question about
      your audio, your model and your campaign's names, so it is now a switch:
      QB_ASR_VOCABULARY_STYLE=hotwords (or none, the free control arm, which
      does not even query the wiki). Unset is initial_prompt and sends
      byte-identical requests to every release before this one
      , so nothing moves
      until you move it. Asking for hotwords from a server that does not declare
      them falls back to the prompt and warns on every capability probe rather than
      running your comparison on the arm you were trying to leave. Each run records
      the style it used in the new nullable summarisation_runs.vocabulary_style
      (migration 4f5a6b7c8d9e), exposed as vocabulary_style on the
      summarisation-run responses, so the comparison can be read back from the
      database instead of from a memory of when the setting changed.

    • [webapp] When a rightsholder writes in, there is now somewhere to put it
      and something to do about it
      (#555). An administrator can record a notice —
      who wrote in, how to reach them, what they claim, what they say it is about,
      when it arrived, and your own notes — and then act on it across everything on
      the instance in one place: disable or remove a pack loaded for the whole
      instance, a pack in any campaign including one you did not import, or
      individual records inside either. Disabling is immediate and reversible and
      takes the content out of every search on the next query; removal is final.
      Whatever you do, the person who imported the content is told on the pack
      itself, in a message naming the notice and quoting the affirmation they
      agreed to at import — the version on their own pack row, not today's wording
      — and every action is written to the audit log with the notice id, so the log
      can still answer "why did this go?" after the pack itself no longer exists. A
      batch of actions is all-or-nothing. There is also one operator-set contact
      address for notices, readable by any signed-in user so a licences page can
      print it. Quest Board decides none of this. Nothing is taken down
      automatically, no notice moves its own status, no counter-notice reinstates
      anything, and there is no repeat-infringer logic — whether a claim is valid
      and what you are obliged to do about it are yours, and the software only
      records and applies what you chose. New migration 2d3e4f5a6b7c adds
      content_notices; no existing table changes. What an operator actually does
      with all this is written up in docs/OPERATIONS.md → "Handling a
      rightsholder notice", and the endpoints are in docs/API.md.

    • [webapp] Import a Foundry VTT compendium export (#553). Upload the folder,
      a zip of it, or the single JSON array people share — Quest Board reads the
      documents either way. Pathfinder 2e and D&D 5e are mapped in full, so their
      creatures arrive as stat blocks rather than as prose, and a system Quest Board
      has no map for still imports as searchable text with its system block kept
      intact for export. HTML descriptions become Markdown, and an actor's own items
      become the strikes and abilities in its entry. Foundry keeps a document's
      publication and licence in four different places depending on whether it is an
      item or an actor and whether the export predates the Pathfinder remaster; all
      four are read, because a creature that imports with no licence is the one
      failure a content pack cannot afford.

    • [webapp] Import the D&D 5e homebrew you already have, from 5etools or
      Dungeon Master's Vault
      (#553). Two more readers for the content-pack
      importer, so people with homebrew in the two places most of it lives do not
      have to convert it by hand. A 5etools homebrew .json brings in its
      monsters as full stat blocks — armour class, hit points, speed, challenge
      rating, ability scores, size and type — along with its spells, items,
      conditions, feats, traps, variant rules, classes and backgrounds; 5etools'
      inline markup is rendered to readable text rather than left as {@hit 7}, so
      what you search is what you would read. A .orcbrew export from Dungeon
      Master's Vault or OrcPub2 brings in its monsters, spells, magic items, feats
      and character options, computes a monster's hit points from its hit dice when
      the file does not state them, and takes the pack's name from the homebrew pack
      the entries belong to, since the format has no header to read it from.
      Neither format records a licence unless the file itself declares one —
      Quest Board does not guess on your behalf. Content in either file that
      Quest Board does not recognise is imported as searchable text with the
      original kept for export, and it tells you what it did rather than dropping
      it quietly; anything it genuinely could not read is reported as a warning on
      the import, alongside the ones the importer itself raises. Adds one backend
      dependency, edn-format, for reading .orcbrew.

    • [webapp] Import Open5e data, and build the two starter packs we are allowed
      to publish
      (#553). The last of the five readers takes an Open5e v2 export in
      whichever shape you saved it — a single API page, a plain array of records, or
      a folder or zip with one file per resource — and brings in its creatures as
      full stat blocks alongside its spells, conditions, rules, items, feats and
      character options. An Open5e record carries no licence of its own: the licence
      belongs to the document it came from, so the reader looks each one up. The
      two SRDs and the Black Flag reference document are CC-BY-4.0, the Kobold Press
      books and the other known third-party documents are OGL 1.0a, and a document
      Quest Board does not recognise gets no licence at all rather than a guess — it
      still imports, and the exporter withholds it. Alongside the reader, two build
      scripts under scripts/content/ produce the starter packs an administrator
      can fetch on request: build_5e_starter.py takes an Open5e export and keeps
      only the srd-2014 and srd-2024 documents, and build_pf2e_starter.py
      takes a Foundry Pathfinder 2e checkout and keeps only the records the ORC
      License covers, dropping creature flavour text because that is where the
      Reserved Material the licence excludes lives. Each writes a .qbpack carrying
      the notice its licence requires — the SRD's Creative Commons attribution to
      Wizards of the Coast, or the ORC Notice with Paizo Inc. named as the licensor
      of the products the pack drew on — and a SOURCES.md beside it listing the
      documents, the counts, and everything withheld with the reason.
      Operators: these packs are published as release assets and fetched on
      request, never bundled in the image, so an operator who wants neither ships
      neither. Each build is pinned to an upstream commit and is byte-identical when
      re-run from the same source, so a content update arrives as a diff someone can
      read rather than as an opaque new blob.

    • [webapp] Load the rules and bestiary content you already have, and look it
      up without leaving Quest Board
      (#553). Quest Board still ships no game
      content of its own — bundling a monster manual would make every operator a
      distributor of it. Instead there is now an importer: an administrator loads a
      pack for the whole instance, a GM loads one for a single campaign, and either
      affirms at the point of import that they are entitled to. Quest Board records
      who affirmed what and when, shows the licence notice the pack carries,
      searches it locally, and exports back only the records whose licence actually
      permits it — the rest are listed in the export with the reason they were
      withheld. This first slice reads Quest Board's own format in three shapes: a
      .qbpack archive, a plain JSON array of records, and a CSV you can write in a
      spreadsheet (kind,name,level,traits,summary,body). A campaign's own version
      of a creature ranks above the instance-wide one of the same name and both are
      shown, each saying which pack it came from; a GM's records stay GM-only until
      they say otherwise. Operators: an administrator can disable any pack on
      the instance — a campaign's included — which removes it from every search
      immediately while keeping the rows, and can remove it outright; both actions,
      and every import and export, are written to the audit log. Descriptions
      imported as HTML are sanitised before they are stored, so nothing executable
      survives an import. New migration 1c2d3e4f5a6b adds content_packs and
      reference_entries; the backend gains two dependencies, nh3 and
      markdownify. Full format reference in docs/CONTENT-PACKS.md. The screen
      that drives it is the next entry; the player-facing search lands with the
      v4.4.0 interface work, and the API is documented in docs/API.md.

    • [webapp] A screen for loading content packs, and for the promise you make
      when you do
      (#553). Administrators get a Content packs tab in Admin for
      the packs the whole instance can search; a GM gets a Content packs panel
      on their campaign page for that campaign's own. Both take a file or a URL,
      let you name the game system and the format (or leave it to detect), and say
      who imported each pack, when, what is in it, and under what licence — with
      the licence's own notice one click away, because that text is attribution the
      licence requires be carried. The affirmation you agree to is fetched from the
      server and shown word for word rather than copied into the app, the box is
      unticked again after every import because affirming is per pack and not per
      visit, and if the wording changes while the page is open the server sends the
      new text back and the box asks again. A pack too large to import in one
      request reports its progress with a clock on it and updates itself when it
      finishes; warnings from an import are shown rather than counted. Export is a
      download link, and removing a pack asks first and names how many records go
      with it. Operators: disabling and re-enabling a pack stay administrator
      actions on both scopes — a GM can see that one of their packs was disabled
      and why, and can remove it, but putting it back is the host's call, since
      disabling is how a rightsholder's notice gets answered.

    Fixed

    • [webapp] Suggested wiki updates no longer arrive in duplicate (#609).
      Every suggestion for a session was written twice — twelve suggestions meaning
      twenty-four rows to read and dismiss — whenever the matching step took longer
      than a minute, which with a self-hosted model is every time. The step that
      writes the suggestions waits for the earlier phases by asking to be retried,
      and that request was being caught and turned into a second retry with the
      same delay, so two identical copies of the job woke together and each wrote a
      full set. The retry is now passed through untouched, and the write takes a
      lock and recognises when its twin has already finished, so a repeated
      delivery does nothing at all. Existing duplicates are not cleaned up
      automatically: press Look for wiki updates again on an affected session
      and its suggestions are replaced with one clean set.

    • [webapp] A long session's summary no longer spends its spare room on the
      start of the evening, and talk that is not about the game stays out of it

      (#603). Since #589 the summary takes one event from every stretch of the
      session that has any, and because some stretches are quiet there are usually
      a few places left over. Those went to whatever was left in the order it
      happened, so always to the start of the evening and never to a busy ending.
      They are now shared out in proportion to how much each stretch still has to
      offer: a stretch where a lot happened gets more of them, wherever it falls,
      and a quiet one keeps its one. A busy stretch's events are chosen from across
      it rather than from its first few seconds, and the summary is now always
      written from the session's last event as well as its first, so its closing
      paragraph is about how the evening actually ended. On the real 54-event
      session behind this fix, that moves three of the thirty-six places into the
      second half of the evening and brings back both of the late events the old
      choice left out. It is a better sample, not a guarantee about any single
      event, and every event is still listed in full under the summary. Separately, the summariser was
      told to leave out talk that is not about the game and recorded it anyway, as
      if it were story — a conversation about another video game arrived as an
      event, and a player's pun reached the prose. It now labels that talk as table
      admin, the same as the rules calls in #596, so it is kept in the event list,
      folded away, and never written into the summary.

    • [webapp] A summary no longer ends on a "What's next?" with nothing after
      it
      (#595). Every summary prompt asked the model to "end with a short
      'What's next?' hook", and Gemma 4 26B took the quoted phrase as the thing to
      write: both reruns of one session finished on a bare What's next? line and
      stopped, which reads as a summary cut off mid-thought. The prompts now ask for
      what that last paragraph should say — one or two sentences on what the party
      is left facing or has decided to do — and if a model still ends on the
      heading alone, the heading is removed before the summary is saved (and before
      any "part of the session could not be summarised" note is placed, so the note
      never lands in the wrong spot because of a heading that was about to go). A
      hook with something after it, or "what's next" anywhere in the prose, is left
      exactly as written. Each run records whether this happened as hook_dropped,
      so whether the new wording holds for a given model is visible across runs.

    • [webapp] The same event, word for word, no longer appears twice in a
      session's events
      (#588, in part). On one real session "Clio discovers the
      water is much deeper…" was listed twice at 00:04:56, and two more events were
      doubled the same way, because deduplication only merged copies that cited the
      same transcript line for the same person, and these copies had drifted apart
      on one or the other. Two events with the same start time and the same words —
      ignoring capitals, spacing and punctuation at either end — are now one; the
      first is kept, unless only the later copy passed the transcript check. Nothing
      cleverer than that: two differently worded descriptions of one moment, or one
      moment split into two events, are still left alone, because telling those
      apart needs judgement and #588 stays open for it. Each run now records how
      many duplicates deduplication removed as beats_collapsed (migration
      8d9e0f1a2b3c, which also adds hook_dropped).

    • [webapp] The GM running the table is no longer written up as the story
      (#596). Replaying a real session, several of the "events" the summariser
      confirmed were the GM running the game: fixing a character's position on the
      map, moving another to the upper layer, explaining what a thresher is (twice),
      asking everyone for their backstories, three on which skill reading a journal
      needed, and three on adding a skill to a character sheet. Each one passed the
      checks, because the GM really did say it, so they filled up the event list,
      took places among the thirty events the summary is written from, and one
      reached the prose as "the group received an explanation regarding what a
      thresher is". Telling the model to leave such lines out had not worked on
      that model, so now it labels them instead, as table admin, and the label
      does the rest: table admin is never written into the summary and never counts
      against it, and in "Everything that happened" it sits behind one quiet line —
      "9 table notes" — that opens them in place, each marked as such. A line
      the model labels as table admin by mistake is therefore still on the page,
      one click away, not lost. The same goes for the GM answering a question about
      something earlier (#584), which is now labelled rather than dropped. Each run
      also logs how many events look like table admin but were not labelled that
      way, which is how we will know whether a given model is using the label.

    • [webapp] A long session's summary no longer skips the start of the session
      (#589). The cap added in #568 keeps the summary to thirty events and picked
      which thirty by kind, preferring reveals, decisions, fights and travel over
      "social" and "other". On the first real session it ran on, that cut the
      session's own opening: the party's long rest, waking up somewhere they had not
      fallen asleep, and working out that the caverns had shifted were all labelled
      "other" by the extractor, so each of them lost its place to a reveal from
      forty minutes later, and the summary began instead with one character walking
      down a ramp. Fifty of that session's eighty-one confirmed events — 62% of
      it — sat in the losing tier, because six labels borrowed from combat and
      intrigue do not describe a session spent exploring. So the thirty are now
      chosen by when they happened: the session is cut into thirty equal stretches
      of time, every stretch that contains anything contributes an event, and the
      labels only decide which event comes out of a stretch. A summary chosen this
      way can still give a moment less weight than you would have; it can no longer
      leave out the part of the evening that set everything else up. Sessions with
      fewer than thirty events read exactly as they did before, and every confirmed
      event is still listed in full underneath the summary either way.

    • [webapp] The summariser no longer takes the GM answering a question as
      something that just happened
      (#584). On one session the party was thrown
      clean across the map by shifting caverns, and every summariser tried on
      it — a 9B model, a 26B one, a frontier hosted one — put them back in the
      place they had been thrown out of. All three were repeating the same
      extracted event, and that event had passed every check: the GM really had
      said "you're in ", a few turns into the players asking where they had
      been before the caves. The words were spoken and the speaker spoke them,
      which is all the validator can decide from the text; the tense was answering
      a question about the past, which it cannot. So the extractor is now told
      that a GM line answering a player's question — about earlier events, the
      rules, the map or a name — is context rather than something happening now,
      and may only be recorded as an event when the lines around it show the
      fiction changing. Nothing is rejected on a guess about tense: a beat whose
      only evidence is a short GM reply just after a player's question is counted
      in the logs so we can tell whether the rule is working, and still goes into
      your summary, because a heuristic sure enough to delete a real event from a
      session is not one anybody has measured yet. That counting was itself blind to
      start with, and found nothing on the very session it was written for: it was
      reading the shortened transcript the summariser is prompted from, in which a
      run of lines from one speaker is merged into a single line — so a GM answering
      in three short bursts looked like one long line of narration, and "short reply
      just after a question" could never match it. It now reads the lines as they
      were actually spoken, and the counts mean what they say.

    • [webapp] The provider concurrency cap now actually applies to the work that
      happens after a session
      (#573). The cap from #356 exists so that a burst of
      finished sessions costs latency rather than failures, and the post-session
      fan-out — storyline, title suggestions, lore proposals, highlights — was
      running entirely outside it. Every LLM call those tasks made logged "Could not
      reach Redis to take a llm slot … (RuntimeError: Event loop is closed); running
      without a concurrency cap for this call", and then did exactly that. Redis was
      fine; the connection to it was not. A Celery worker runs each task in its own
      asyncio.run(...), an async Redis connection belongs to the event loop it was
      opened on, and the pool holding it was shared for the life of the process — so
      from the second task onwards the worker was asking a closed loop for a slot.
      Because the cap fails open by design, nothing looked broken: a self-hosted
      llama.cpp simply took more concurrent requests than it declares and slowed all
      of them down together, which is the failure #356 was written to prevent.
      Connection pools are now kept per event loop, and a closed loop's pool is
      dropped — the same answer app.database.task_session already gives for the
      database. The one other async pool shared across the process, behind the bot
      event stream, is fixed with it: its two beat tasks would have failed more
      quietly still, since the unread-trim check reads any error as "no stream yet"
      and reports no data loss.

    • [webapp] Session summaries are told the truth about the order events
      happened in
      (#566). Quest Board extracts the events of a session more than
      once — a second pass reliably finds things the first missed — and then hands
      the merged list to the model that writes your summary, with the instruction
      that it is "already in chronological order". It was not: each pass was sorted
      on its own and the passes were then stacked one after the other, so the list
      read as the whole session followed by the bits found on the second look. Every
      summary was affected, because every run takes at least two passes. The list is
      now put in time order at the door the compose step cannot go round, so the
      order the model is told it has is the order it gets — and the summary follows
      the session rather than the order the events happened to be noticed in.

    • [webapp] The quote board reads the whole session again, instead of
      whatever fitted
      (#575). Picking the memorable lines out of a session was the
      one step that still sent the entire transcript in a single prompt, however
      long the session was. On a real one that came to about 31,000 tokens against a
      22,900-token budget, and the log said as much before sending it anyway — so a
      local server that quietly drops the front of an over-long prompt answered from
      the tail of the session, without its instructions, and the quotes came back
      thin with nothing to explain why. The transcript is now split into windows
      that fit, exactly as the summariser's own reading of it already was, and each
      window is asked separately. The number of quotes you asked for is still the
      number you get: they are taken a few from each part of the session rather than
      all from the first, so a four-hour game no longer produces a quote board made
      entirely of its first twenty minutes, and "key moments" are capped for the
      session instead of per call. One window failing no longer loses the rest of
      the board. Nothing changes for a session that already fitted in one prompt.

    • [webapp] A part of the session the summariser could not read is retried,
      and then said out loud
      (#572). Quest Board reads a session for events in
      consecutive windows, and a window whose call to the model failed was skipped:
      the summary simply did not cover that stretch, and the only trace was a line
      in the worker log that ended with the provider's error message — which for a
      local llama.cpp router is the empty string, so it read Beat extraction failed for window 4/5 (pass 1): and stopped there. On the session that prompted
      this, window 4 of 5 was the payoff, it failed on every pass, and the summary
      covered none of it with nothing anywhere to say so. Three changes, all in the
      direction of saying what happened: the log now names the exception's type and
      its repr, and describes "the model returned nothing" in different words from
      "the call failed"; a window that fails is asked once more before it is given
      up on, since these failures come and go per window; and if it still returns
      nothing on every pass, the summary itself ends with a plain sentence naming
      the missing range — "Part of the session (from 00:41 to 00:52) could not be
      summarised because the summariser returned nothing for it; the transcript is
      complete" — placed before the "What's next?" hook. A quiet window that the
      model read and found nothing in is not a failure and says nothing. Which
      windows were lost is also recorded on the summarisation run as windows_failed
      (migration 7c8d9e0f1a2b), so the run history can be asked how often this
      happens on a given endpoint.

    • [webapp] The check that watches for invented people stopped reporting
      "Fortunately" as one
      (#567). After writing a summary Quest Board reads it
      back and looks for anyone the verified events, the speakers and the characters
      cannot account for — a cheap guard against a model adding a person nobody
      played. It decided what a name was by capitalisation, so every sentence that
      opened with an ordinary word was a person: real runs reported "Early",
      "Fortunately", "Undeterred", "Continuing", "Ahead", "Getting", "Whatever",
      "Among" and "Rounding". English capitalises the first word of every sentence
      whether or not it is a name, so that position is now treated as saying
      nothing: an opening word counts only if it is a name Quest Board already
      knows, or if the summary uses the same word capitalised mid-sentence, where
      grammar does not explain it. A genuinely invented name still gets reported the
      moment the prose mentions it anywhere but a sentence's first word — which
      composed prose does, because it introduces a person and then talks about them.
      This has always been a log line and never changed or withheld a summary; what
      changes is that the log line means something again.

    • [webapp] Transcripts no longer contain the things Whisper says about
      silence
      (#569). Speech recognition writes text for near-silent audio, and it
      writes the same things every time: television caption credits ("Sous-titrage
      ST' 501", "Closed Captioning by …", "Red Bee Media"), a bare "Thank you.", a
      lone full stop, one word looped a dozen times, a fragment in a language nobody
      at the table speaks, and — because Quest Board tells the model your campaign's
      proper nouns before it listens (#355) — one of those nouns handed straight back
      to us. One real six-speaker session carried 143 of these among 1,778 lines.
      They cost tokens in the summariser's context, they were cited as evidence for
      events that never happened, and a model that reads "Thank you." seventy-eight
      times writes about how polite everybody was. A filter now drops them before
      the transcript is assembled, matching whole lines only — "Thank you." goes,
      "Thank you, Kira, that was genuinely heroic" stays — and never a name a person
      might have said on its own, so "Wilhelmina?" survives while "Office of
      Prophetic Fulfillment" does not. What it removed is counted per rule on the
      session's summarisation run, so a GM can see that lines were taken out and how
      many; the lines themselves are never logged. Migration 3e4f5a6b7c8d adds one
      nullable column to summarisation_runs.

    • [webapp] Pages served beside the app on the same hostname no longer get
      bounced to the dashboard
      (#535). The installed app's service worker answered
      every navigation on its hostname with the app shell, so a page the reverse
      proxy serves under a path of its own, such as the design-review feedback site
      at /feedback/, loaded as Quest Board and was sent to the dashboard by the
      router, in any browser that had visited the app before. The worker now leaves
      /feedback/ alone, uncached, as it already did for the API and the sign-in
      flow. Browsers that hold the old worker pick up the new one on their next
      visit and are right after one reload.

    • [webapp] Editing a summary and then clicking away no longer loses what you
      wrote
      (#464). Correcting a session's summary or transcript and then heading
      off somewhere else — the nav bar, a campaign link, the browser's Back button —
      discarded every word of the correction without a murmur. Closing the tab or
      reloading had asked first since #410, but a click inside Quest Board never
      leaves the page as far as the browser is concerned, so nothing was there to
      ask. Leaving a half-written edit now stops and asks whether to discard it, and
      staying put leaves the text exactly where it was. Nothing changes if you have
      saved, or if you opened the editor and typed nothing.

    • [webapp] "Saved" now stays on screen under a reviewed stat block (#182).
      Correcting a proposed stat value on a wiki draft autosaves it, and the little
      status line beside the stat block was supposed to say so. It did — for a
      single frame, because the same code that noticed the form was no longer
      unsaved immediately reset the line back to blank. In practice the autosave
      looked like it had done nothing, which is the one thing a silent background
      save must never look like. The line now reads "Saved" until the next edit.

    Internal (no operator-visible change)

    • [repo] The v4.4.0 design record and a self-hosted feedback site (#361,
      #362, #535). docs/design/v4.4.0-concepts/ holds the brief's sources, the
      surface inventory, the wireframe kit and the sixteen concept fragments that
      build the checkpoint A review pages. tools/feedback-site/ is a small,
      separate container (FastAPI, a JSON-file store) that serves the players' and
      GMs' review pages with a form and an admin readout, so beta testers without
      accounts can answer. It shares nothing with Quest Board and is not deployed
      with it.

    • [webapp] Backend support for an admin view of every campaign on the
      instance
      (#437). GET /api/admin/campaigns returns each campaign's
      ownership, member/session counts, Discord routing flags and retention
      overrides regardless of the caller's memberships — metadata only, no campaign
      content. The Admin screen that uses it lands later in the milestone.

    • [webapp] Adopted the React Compiler lint rules from
      eslint-plugin-react-hooks v7
      (#182). The frontend enabled two of the
      plugin's rules by hand and deliberately skipped its recommended preset,
      which in v7 also brings the compiler's own checks — purity, refs,
      immutability, set-state-in-effect and a dozen more. The preset is now on in
      full, and the forty violations it found across twenty-two files are fixed
      rather than suppressed: mostly loading and "stale" flags that an effect
      raised on its way past, rewritten as values derived during render from what
      the data in hand was fetched for. There are no eslint-disable comments for
      any of it. Running the compiler itself over the build remains a separate
      decision that this change does not make.

    Downloads
  • v4.3.0 9dd77986b5

    v4.3.0
    All checks were successful
    CI / Bot/backend version sync (push) Successful in 42s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Backend lint (ruff) (push) Successful in 55s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m29s
    CI / Frontend tests, audit, and build (push) Successful in 2m16s
    CI / Bot tests and audit (push) Successful in 2m36s
    CI / Docker image build (push) Successful in 4m13s
    Release / Build and push versioned images (push) Successful in 3m53s
    CI / Backend migration, tests, and audit (push) Successful in 10m13s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m47s
    Stable

    claude-bot released this 2026-09-06 09:51:10 +00:00 | 2430 commits to main since this release

    Product Surface: Navigation, Attention & Feedback. The features Quest Board already had are now findable, acknowledged and self-diagnosing: a navigation bar on every page, one name for each thing, a "Needs you" list on the dashboard, RSVP on the web, a session page that shows every post-session task, a Prep workspace with a front door and a sheet that looks forward, a wiki you can search and review from one place, a Discord bot that answers back and explains itself, and an invite link you can paste. Structural fixes, not a redesign; that is v4.4.0.

    Three migrations, applied in this order by the migrate container: f0ab1c2d3e4f (a delivery-status column on campaigns, #369), fa0b1c2d3e4f (an RSVP status on session attendance, #370), 0b1c2d3e4f5a (post-session task status on sessions, #380). All are additive; nothing is backfilled. Head after upgrade: 0b1c2d3e4f5a.

    Bot API contract v1, unchanged. Everything new on /api/bot/* is additive: POST /bot/campaigns/{id}/delivery-status, GET /bot/guilds/{id}/campaigns, GET /bot/guilds/{id}/sessions/recordable, a state field on link-status, a reason field on /ask, and a link field in every notification payload. An older bot works against this backend; an older backend leaves a newer bot's new features quietly off. Deploy both images together as usual. BOT_EXPECTED_APP_VERSION is bumped so the bot's startup log matches.

    One new public route on the backend: GET /api/invites/{code} resolves an invite code to a campaign preview so /join/<code> links work before sign-in. The code is the secret; the route is rate-limited and a wrong code is an ordinary 404. Nothing else on /api is unauthenticated.

    Two HTML pages served by the backend (the account-linking outcomes) carry a slightly relaxed Content-Security-Policy that allows inline styles for text/html responses only; JSON responses keep the strict policy.

    No new settings. Nothing to add to .env. Operator notes carried from the patch releases: the Message Content toggle in the Discord Developer Portal can be turned off; if a campaign's notification channel is misconfigured the campaign page now says so, and the per-field "Test" buttons on the Edit form prove a fix. The old /sessions/<id>/recording address redirects to the session page, which is now the one live-session screen.

    Added

    • [webapp] You can now RSVP to a session in Quest Board (#370). Saying whether
      you were coming used to mean reacting ✅ or ❌ to a message in Discord — so if
      your group does not live in Discord, or you never linked your account, you
      could not answer at all, and nobody could see who had. A confirmed or
      tentative session now has an "Are you in?" control at the top of its page with
      Yes, Maybe and No, and the whole party can see the roster of who has answered
      and who is still to. It is the same answer either way: a reaction in Discord
      shows up in the roster, and an answer given here shows up for everyone. Your
      GM's after-the-fact attendance list is untouched — saying you are coming is not
      the same as having been there.

    • [webapp] A "Needs you" list on the dashboard (#368). The dashboard showed
      your campaigns and nothing else, so the only way to find out that a vote was
      waiting on you, or that a session next week still needed your RSVP, or that a
      transcript had been sitting ready for you to approve, was to open each
      campaign and look. Anything actually waiting on you now appears at the top of
      the dashboard — open votes, missing RSVPs, sessions in progress, and for GMs
      transcripts to review and suggested wiki updates — and every line takes you
      straight there. When nothing is pending the section is simply not there. The
      "Next session in 3d 4h" line on each campaign is now a link to that session.

    • [webapp] A "Post-session processing" checklist on every session (#380).
      Finishing a recording sets six things going: the transcript and summary, the
      suggested wiki updates, the campaign storyline, the session's journal entry,
      the next session's title ideas, and the recap email to the party. Only the
      first two ever said anything, so if the storyline never rebuilt or the recap
      email was refused by the mail server, nothing anywhere told you — it looked
      exactly like a job that had not got round to it yet. All six now appear as a
      checklist on the session page, and "failed" reads differently from "hasn't
      run" and from "deliberately skipped" (journal entries turned off, campaign
      archived, storyline written by hand). The recap email says whether it is
      queued, sent, or failed. Includes a migration (0b1c2d3e4f5a), which
      adds one column and backfills nothing — sessions processed before this
      release read as "no record", because that is the truth about them.

    • [webapp] A navigation bar that is the same on every page (#378). Until now
      the only thing every screen shared was a corner strip with your name, Profile
      and Sign out; getting from a session to the wiki meant walking back up the
      breadcrumbs one page at a time. There is now a single bar across the top of
      the whole app: the Quest Board wordmark always goes home, a switcher jumps
      straight to any of your other campaigns, and while you are inside a campaign —
      including on a session, its recording page or its shelf — the bar carries that
      campaign's Sessions, Prep, Wiki and Highlights. The two wiki links on the
      campaign page also stop opening a second browser tab, so the back button
      works through the wiki again.

    • [bot] /help, a welcome when the bot joins a server, and a presence that
      points at both
      (#373). The bot arrived in a server and said nothing, ever:
      there was no command list, no message on join, and nothing under its name in
      the member list, so the only way to find out that /next existed was to be
      told by somebody who already knew. /help now lists every command with one
      line each; joining a server posts the three setup steps — link a campaign to
      this server (quoting the server's own ID, ready to paste), everyone runs
      /link, then try /help — and the member list reads "Watching Quest Board ·
      /help". The welcome also warns that Discord takes up to an hour to roll
      out a new bot's slash commands, because the first impression of a working
      install was otherwise a server where /help did not exist.

    • [webapp] The wiki has a search box (#396). Until now the only way to
      reach an article was to know which of thirteen categories it had been filed
      under and scroll the list — fine for a campaign with twenty articles, useless
      for one that has been recorded for a year. There is now a search box on the
      wiki's front page and in the article page's category rail. It searches every
      article in the campaign rather than just the ones already on screen, and it
      looks at each article's text and its alternative names as well as its title,
      so "the innkeeper with the limp" finds the innkeeper even when nobody at the
      table remembers his name.

    • [webapp] An invite link you can actually send someone (#387). Inviting a
      player used to mean finding a 12-pixel "▼ Show invite code" disclosure under
      the Archive and Delete buttons, copying eleven characters out of it, and
      hoping the recipient worked out that they had to create an account, find the
      Join button on their own dashboard, and paste it in. There is now an Invite
      players
      control in the member list, which gives you a link — paste it into
      your group chat and whoever opens it sees which campaign they have been
      invited to and who invited them, signs in if they need to, comes straight back
      to the invite, and joins. It works for somebody who has never heard of Quest
      Board. The code is still there underneath for when a link will not survive the
      trip, and the "+ Add" member search now says so too, instead of ending at "No
      users found." — which it always did for the one person you were most likely to
      be looking for.

    • [webapp] A first-run checklist on a new campaign (#387). A campaign you
      had just created looked exactly like one you had finished setting up: an Edit
      form, a member list of one, and an empty session list. Four things actually
      need doing — set the timezone, invite the players, connect Discord, schedule
      the first session — and each was discoverable on its own but not as a set.
      They now appear as a short card at the top of the campaign page, each with a
      button that takes you to the control that does it rather than near it. Items
      tick themselves off from the campaign's own state, so a GM who did all four
      last year never sees it, and Dismiss makes it go away for good on that
      campaign.

    • [webapp] Guides for GMs and players, one click from where you are (#390).
      Everything written down about Quest Board was for whoever installs it. A GM
      running their first campaign, or a player handed an invite link, had the
      interface and nothing else. There are now two guides — "Running your first
      campaign" and "Joining a campaign" — that live in the app, linked from the
      bottom of your dashboard, from the invite page and from the sign-in page. They
      are readable without an account, on purpose: the reader who needs "Joining a
      campaign" most has not signed up yet. The sign-in page also stops describing
      the product as scheduling, which it has not only been for a long time, and its
      button says "Sign in" rather than "Sign in with SSO".

    • [webapp] Players get the "Previously on" recap too (#388). A short recap of
      last session, and the quests still open, has been waiting at the top of the
      GM's shelf since v4.1 — where nobody but the GM could ever see it. The same
      strip now sits at the top of a session's page and of "At the table", for
      everyone in the campaign, while the session is upcoming or in progress. It is
      the same recap from the same place, narrowed rather than rewritten: players
      see the open quests your GM has marked visible to the party, and not the ones
      they haven't. You can fold it away, and it stays folded for that session on
      that device, but it comes back with one click — it is not a dismissal you can
      make by accident and never undo.

    • [webapp] Three things you could do, but had no way to undo or reach
      (#389). Showing a card to the table was one-way: once revealed it stayed on
      the players' table view for the rest of the session, however plainly you had
      meant to reveal a different card. The shelf now lists what is currently up,
      with an "Un-reveal" on each — and the players' view drops it on its next
      refresh, exactly as it picks up a reveal. XP was the same shape of problem
      from the other side: the ledger showed totals only, so a mistyped award could
      be made and never unmade. Every award is now listed, party and per-member,
      each with a delete that names the award it is about to remove. And "Add to
      Calendar" offered Google and nothing else, which is a dead end if you keep
      your calendar in Apple Calendar, Outlook or Thunderbird; there is now a
      "Download .ics" beside it, offered wherever the Google link is.

    Changed

    • [webapp] One screen for a live session (#386). Running a session meant
      keeping two pages open that described the same recording and disagreed about
      it. The session page had a "Recording & Transcript" section; a separate
      Recording page had the live picture — the clock, who the bot could hear, who
      was in the voice channel and silent, and Start and Stop — plus a second copy
      of the summary you could not edit and a second attendance list whose toggle
      said "✓ Here" where the session page said "Present". Worse, the link to that
      page only appeared once a session had a confirmed time, so the one moment you
      most want to check the bot can hear the table — before committing to a date —
      was the one moment the controls were out of reach. All of it is now on the
      session page: a Recording panel with the live clock, per-speaker capture,
      who is present but not captured yet, Start and Stop, and the "connection
      lost, showing the last reading from … ago" honesty added in #377. It is there
      from the moment a session is proposed, and the shelf — the screen a GM
      actually has open during play — now has a Recording link straight to it. The
      summary is editable in one place and read-only in none, attendance says
      "Present" or "Absent" wherever you meet it, and the roster is available from
      the moment a session is under way rather than only after it is over. The old
      /sessions/…/recording address still works: it takes you to the panel, so
      bookmarks and older Discord links are unaffected.

    • [webapp] One name per thing, across the whole app (#379). The same concept
      answered to a different name on almost every screen, which made two screens
      about the same thing look like two different features. The GM prep workspace
      was "GM Workbench" in its own heading, "Session Prep" in the palette and
      "Session Prep Sheet" in the panel; it is Prep everywhere now, and its
      output is "prep notes". The wiki landing page said "Wiki" in the breadcrumb
      and "Campaign Story" in its heading; it says Wiki. Plot threads were
      "Thread Tracker", "loose ends" and "open loops"; they are Quests, and the
      shelf's cold open lists "Open quests". The Quote Board is Highlights. A
      suggested wiki entry was "AI Proposals", "Entry Proposals", "Bot Proposals" or
      "lore proposals" depending where you stood — and "Bot" was wrong besides,
      since the backend worker writes them, not the Discord bot; they are
      suggested wiki updates. The generation history rail and its filter now
      name each tool ("Session Prep", "NPC Voice Cues") instead of printing the
      backend's session_prep and npc_voice at you. The link from a session to
      its recording page says "Recording", not "Dashboard". Nothing moved: every URL
      is unchanged, so bookmarks and Discord links still work. A character's
      timeline also stops rendering twice on a wiki article — the full section
      stays, the cramped duplicate in the infobox is gone.

    • [webapp] "Approve" on a suggested wiki update stops meaning two different
      things
      (#385). One button either wrote the article straight into the wiki
      or carried you off to a merge-review page you had not asked for, depending on
      whether the suggestion changed an article you already had — a difference
      signalled only by a small "Update" chip. A suggestion that creates a new
      article now says Approve; one that changes an existing article says
      Review merge…, so the destination is on the button. The words either side
      of that click have been made true as well: the queue promised the suggestion
      would be "appended" and the review page then offered to "replace the
      article"; every draft that was not a merge or a stat conversion called itself
      an "LLM rephrase", generated backstories included; and the approve button
      read "Approve and replace article" even for the two kinds of draft that
      replace nothing at all. Each kind is now named for what it is and its button
      for what it does — "Generated backstory for…" approves as Add to article,
      a stat block approves as Approve stat block and says the article's text is
      left alone. Alongside that: all four actions in the queue now show they are
      working, so a second click cannot approve or discard the same thing twice;
      when one fails it says so on the suggestion that failed, instead of in a
      browser alert box — the last one in the product; and approving an article
      confirms it and offers a link to the article you just made, rather than
      making the row vanish and leaving you to guess.

    Fixed

    • [webapp] Pages that are not for you now say so, instead of vanishing or
      bouncing you
      (#382). A player who followed a Prep link a GM had shared was
      redirected to the campaign page with no message, which reads as a broken link
      rather than as "Prep is the GM's workspace" — it now says which it is, and
      offers the way back. Four wiki pages ended a player's navigation with the
      words "GM access only." on an otherwise empty screen, with nowhere to go but
      the back button; each now explains the restriction and links back to the
      article, the wiki, the campaign and the dashboard. Opening an article's
      /edit URL without permission silently showed the ordinary read view, as
      though the link were broken; it says you can read but not edit this one. The
      "Add to shelf" control disappeared entirely when a campaign had no upcoming
      session, so the answer to "why can't I put this on a shelf?" was a blank
      space; it now says there is no upcoming session and points at where to
      schedule one. A highlight's "Draft" badge said only that it was not visible
      to players, never what would make it visible; it now names the thing that
      publishes it — approving the session summary, which is on the same page. And
      the archived-campaign banner named no way back: archiving is something a GM
      can do, un-archiving is not, so the banner now says an instance administrator
      restores it, and that scheduled sessions stay on the calendar but stop
      sending reminders.

    • [webapp] Prep's session pickers open on your next session, not the one
      furthest away
      (#302). Every session dropdown in Prep — Session Prep, Beat
      Planner, the Name Generator's session context, Series Titles, "Add to shelf"
      — was seeded from whichever session the API happened to list first, which is
      the most recently created one. For a weekly game that is the last occurrence
      on the calendar, so a GM who opened Prep to plan Tuesday was silently pointed
      at a session three months out, and notes saved there landed on the wrong
      night. Those lists are now ordered by when the session actually is, soonest
      first (undated ones last), and default to the session in progress if there is
      one, otherwise the next one up.

    • [webapp] Prep notes can no longer be wiped by a button you did not mean to
      press
      (#303). "Save Notes" in the Beat Planner is now inert until you have
      actually changed something, so an editor you opened and did not type in
      cannot write an empty note over what was stored — and if the notes fail to
      load at all, the box and the Save button are both disabled and say why,
      rather than presenting a blank page you could save. "Append to prep notes" on
      a generated prep sheet re-reads the stored notes at the moment you click it,
      so notes you edited in another tab since the sheet appeared are kept, and the
      button is not offered at all when those notes cannot be read. Notes written
      by the prep sheet also show up in the Beat Planner without a reload, and if
      you had unsaved typing when that happened, your typing wins and you are told.

    • [webapp] Prep no longer throws away what it just generated (#304). Switching
      tools in Prep wiped the sheet you were reading, and "Re-open" in Generation
      History only moved you to the right tool and then showed you an empty panel —
      even though every run had been saved on the server the whole time. Re-open now
      brings the run back: its output, and the inputs it was made with. Opening a
      tool also brings back its last run when that run is still about what you are
      looking at — the same session, say — and deliberately shows nothing when it is
      not, rather than handing you a prep sheet for a session that already happened.

    • [webapp] The generated prep sheet stops handing you back last session
      (#305). The only forward-looking thing the prep prompt had to work with was
      your notes for the upcoming session — which were almost always empty, because
      nothing in Prep ever loaded them (#302, #303). Left with a pile of recent
      summaries and asked for a prep sheet, the model wrote a recap. Three changes:
      the notes box for the selected session is now in the Session Prep panel
      itself, so writing down where you want the evening to go is part of asking
      for a sheet; a "What I want from this session" box steers one sheet without
      being saved anywhere ("they finally meet the Duchess", "keep it in the
      sewers", "no combat"); and the prompt now says what the recent summaries are
      for — build on them, do not repeat them — with every scene and secret
      required to move past where they left off, and only "loose ends" allowed to
      look backwards.

    • [webapp] Prep has a front door, opens on the right tool, and its output
      goes somewhere you can find
      (#374). You could only reach Prep from the
      campaign page or the nav bar, and it opened on the Beat Planner with a
      campaign-wide picker that had already chosen a session — usually not yours.
      Every upcoming session row on the campaign page, and every session page you
      run, now has a Prep this session button that takes you straight to the
      prep sheet for that session. Prep opens on Session Prep. Untitled sessions
      read as their date in every picker instead of four identical rows saying
      "Untitled session". The three unlabelled buttons under a generated sheet
      ("Append to prep notes", "Replace prep notes", and a second shelf picker) are
      now one Save to this session and one Add to shelf, each of which names
      the session it wrote to and links there. Generation History refreshes when a
      run finishes, instead of insisting there were no generations while one sat on
      the screen beside it. And the tool palette is six entries — Session Prep,
      Story Arcs, Quests, NPCs, Names, At the Table — instead of thirteen siblings:
      nothing was removed, the smaller tools now sit inside the job they serve.

    • [webapp] "Processing…" now tells you how long it has been, and how long it
      usually takes
      (#381). A spinner with no clock is indistinguishable from a
      hang after about ninety seconds — which is roughly when a GM decides the thing
      is broken and hits Retry, adding a second job to the queue that was the delay.
      The session page, the recording page and every Prep tool now count the time
      elapsed and say what normal looks like beside it ("usually 2–5 minutes for a
      3-hour session, longer when other recordings are queued"). A Prep run that
      overruns also points at "Generation History" by its actual name, instead of
      telling you to check "the history rail", which is not a thing on the page.

    • [bot/webapp] Discord account linking no longer dead-ends (#392). Two of
      the ways linking can fail answered the browser with raw JSON — literally
      {"detail":"Link token is invalid or expired"} on a blank page — and one of
      them was a trap. If the Discord account was already connected to a different
      Quest Board account, the bot's poller could not tell that from "hasn't
      clicked the link yet", so ten minutes later it DMed the player that their
      link had expired; they ran /link again, got the same silence, and went
      round a loop that /link could never resolve, because the fix was
      /unlink on the other account and nothing had ever said so. Both failures
      now render a real page that names the next step, the bot reports the actual
      outcome (and reports it as soon as it happens, rather than at the end of the
      window), and the conflict message names /unlink. Two smaller silences went
      with it: a "you need to link" DM to somebody with DMs closed used to vanish
      entirely — it now falls back to a short self-deleting note in the channel —
      and the vote reminder that lists players who are not linked now tells them
      to run /link. The profile page names the command too, instead of alluding
      to "the bot's account-linking flow".

    • [bot/webapp] /record start knows which session you mean, and a restart no
      longer swallows a recording in silence
      (#393). The command asked for a
      session ID that nothing in Discord could give you — /next shows one session
      and omits its id, /history lists only finished ones — so the answer was to
      go and copy a UUID out of a browser. It now offers this server's upcoming and
      in-progress sessions as you type, and a session that does not exist is
      refused when you start rather than discovered at the end of the evening, which
      is when the mistake used to surface. Every recording started from Discord also
      announced itself as "Recording started — Unknown Session", and said it again
      at upload, because the slash command fetched the session's details and then
      dropped the title; it now uses the real one. /record stop no longer leaves
      "Quest Board is thinking…" on screen for ever — it answers the person who ran
      it, as well as posting to the channel. And a bot restart mid-session used to
      leave the channel showing "🔴 Recording started" with no ending, /record stop insisting no recording was active, and a GM with no way to know the
      audio had in fact been kept: the active recording is now remembered outside
      the process, so when the bot comes back it says what happened to the audio —
      recovered and being transcribed, kept and awaiting a retry, or never captured
      — and /record stop answers honestly instead of denying the session
      happened. Failure messages have been rewritten for the people reading them:
      no raw exception text in a public channel, and "a GM can retry from the
      session page" with a link, rather than advice to use a console that a hosted
      customer does not have.

    • [bot/webapp] Discord now tells you what actually went wrong (#373). Every
      Quest Board API call the bot made ended the same way when it failed — "Could
      not reach Quest Board right now. Please try again shortly." — including when
      the backend had answered with something you could act on. "LLM endpoint not
      configured. Set it in Admin → Bot Settings." was being thrown away and
      replaced with a message that said the service was down, which it was not.
      The bot now shows the backend's own sentence when the backend is answering
      rather than failing (a 4xx, or a 503 naming a dependency that is not set up),
      and keeps the generic line for a genuine outage or an unhandled error, where
      it is the only true thing to say. Related: /next, /quests and /history
      used to answer "nothing scheduled" for a server nobody had finished setting
      up — they now say when no campaign is linked to this Discord server, name
      the field to fill in, and quote the server's ID; /record start's "you are
      not the GM" refusal now mentions that Quest Board only recognises a GM whose
      account is linked, and points at /link; and /ask on a campaign with
      nothing recorded finally gives the "record a session first" advice it always
      had written down, which a placeholder answer from the backend had made
      unreachable.

    • [bot/webapp] A wrong Discord channel ID no longer swallows every
      notification in silence
      (#369). The bot notification channel is a snowflake
      a GM types into a plain text box. Get it wrong — a typo, a channel that was
      later deleted, one the bot cannot see or cannot post in — and every
      notification that campaign would ever send vanished, permanently, with no
      error anywhere: the bot logged a warning nobody reads, and the durable event
      queue acked the event exactly as if it had been delivered. The campaign simply
      went quiet, and the only visible clue was the absence of messages nobody was
      looking for. The bot now records why it could not deliver, against the
      campaign, and the campaign page carries a warning at the top of the page —
      outside the collapsed Edit form, where the broken field lives — naming the
      actual problem ("Discord has no channel with that ID", "the bot isn't allowed
      to see that channel", "give it Send Messages permission") and offering a
      button that opens the field. The warning clears itself the moment a
      notification lands. The event is still acked, because a message the bot cannot
      post must not wedge the queue for every other campaign — but if the failure
      cannot even be recorded, that now goes to the log as an error rather than
      disappearing too. The "Send test message" button is fixed in the same breath:
      it was admin-only on a page any GM can open (so most GMs got a permission
      error), it reported success on having queued the attempt rather than on
      anything arriving, and it tested the bot channel and the webhook as one thing.
      There are now two buttons, one per field, each waiting for the real answer,
      and the channel ID field itself rejects anything that is not 17–20 digits
      before you can save it. Adds a migration (f0ab1c2d3e4f).

    • [bot/webapp] Voting in Discord now answers back, and a GM can change one
      time without cancelling the vote
      (#371). Reacting to a poll or an RSVP
      message did nothing visible: no confirmation, no error, and a vote that failed
      to reach Quest Board looked exactly like one that landed. Every reaction now
      gets a short reply in the channel that mentions you and deletes itself — "your
      yes for Option A is recorded", or the reason it was not — including,
      at last, the rule nobody could have guessed: removing a reaction records a
      no for that time, not a blank. The poll message says so itself now, and
      says to use Quest Board for "maybe", which reactions cannot express. Four
      further asymmetries go with it. A vote cast in Discord now fires the same
      "🗳️ Vote Update" post that a vote cast in the web app always did, so the
      channel's own tally stops going stale. A reaction on a poll for an
      already-confirmed session is refused instead of quietly recorded, the reaction
      is taken back off, and the player is told when the session was confirmed for —
      and the poll message is relabelled "Voting closed" the moment a time is
      confirmed, so it stops inviting reactions in the first place. A GM can add or
      remove a single time option on an open vote from the session page, keeping
      every vote already cast; the only previous route was "Reopen for voting",
      which replaced the slate and discarded them. And when the bot has to fall back
      to keeping its poll bookkeeping in memory — no Redis, or Redis unreachable —
      it now says so loudly at the moment it starts, and logs every reaction it
      drops, because the symptom otherwise is a channel full of polls that look
      perfectly alive and record nothing.

    • [webapp] The wiki updates the app suggests are findable again (#375). On
      the wiki's own front page the only way into the suggestions queue was a link
      in the left-hand rail, and that rail is hidden on anything narrower than a
      tablet — so from a phone there was no route to it at all. It now sits beside
      New article in the main column, with the number of pending suggestions on
      it, at every screen size. The campaign page carried a second, cut-down copy
      of the queue: it left out suggested relationships entirely, truncated every
      suggested article to three lines, and disappeared when the queue was empty —
      which is exactly the moment a GM hunting for it needed to be told there was
      nothing waiting. That copy is gone, replaced by one row that is always there
      and always says which of "3 suggested wiki updates", "no pending suggestions"
      or "couldn't load them" is true, with Review going to the one page that
      shows the whole queue. Both pages now tell a failed read apart from an empty
      one, instead of showing you an empty queue when the request simply failed.

    • [webapp] A wiki draft can no longer claim to be generating for ever, and a
      failure in one panel stops taking the whole page with it
      (#395). While the
      LLM writes a draft, the review page asks the server for it every couple of
      seconds — and that check only understood one kind of failure. A server error,
      a dropped connection or a worker that died mid-run left "Generating draft…"
      on screen indefinitely, with nothing to suggest anything had gone wrong. The
      page now says when a check fails, stops pretending it is still watching after
      three minutes, and offers Check again beside the existing reset. Three
      other failures used to replace an entire page with a single red line: a wiki
      article's edit history failing to load, a Restore of an old version
      failing, and the wiki's Load more failing each wiped out the article — or
      the whole campaign story — you were reading. Each now reports itself beside
      the control that failed and leaves the page alone. And Full regenerate,
      which throws the campaign story away and rewrites it from every session, now
      asks first, like every other destructive action in the wiki.

    • [webapp] The wiki's category list stops running out after 200 articles
      (#396). The article page's category rail asked for the entire wiki in one
      request, and the server sends at most 200 entries — so on a campaign with
      more than that the rail simply ended, with nothing to say anything was
      missing, and every article past the cut-off was unreachable by browsing. It
      now pages the way the wiki's front page already did, with the same Load
      more (200 of 250)
      button, so both lists reach the end of the wiki.

    • [webapp] Things that failed now say they failed (#377). A dozen places in
      the web app treated a failed request as if nothing had happened, so there was
      no way to tell a working screen from a broken one. The worst of them was the
      recording page: if the connection dropped mid-session, the page redrew itself
      as "No active recording", idle, with a Start Recording button — while the
      bot was still recording in Discord — because "the server says nothing is
      recording" and "we could not reach the server" were the same value in the
      code. It now keeps the last thing the server actually said, says "Connection
      lost, retrying…"
      over it with how long ago that was true, and clears the
      warning the moment it hears back; where it has never heard anything, it says
      Unknown rather than guessing at the answer that invites a second
      recording of the same evening. The players' table view says the same thing
      when its own polling stops working, instead of quietly showing a frozen list.
      Elsewhere: a vote the server refused, and a session-shelf card whose new
      position never saved, both now say so and offer to try again — the shelf was
      the sneakiest, because the board looked right until the next reload put every
      card back. A session's attendance and its loot & XP stop reporting a failed
      read as an evening where nobody came and nothing was won. The last session's
      recap, the title suggestions on a new session, and Prep's name pre-generation
      each say when they could not load rather than simply not appearing. And in the
      wiki, "Check for link suggestions" answers "No link suggestions found"
      instead of doing nothing visible, and a stat block whose game-system schema
      could not be read says why its labels have turned into raw field names.

    Downloads
  • v4.2.3 64b5e15920

    v4.2.3
    All checks were successful
    CI / Docker image build (push) Successful in 24s
    CI / Backend lint (ruff) (push) Successful in 36s
    CI / Bot/backend version sync (push) Successful in 38s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 50s
    Release / Create Forgejo release (push) Successful in 14s
    CI / Bot tests and audit (push) Successful in 1m36s
    CI / Frontend tests, audit, and build (push) Successful in 1m44s
    Release / Build and push versioned images (push) Successful in 4m52s
    CI / Backend migration, tests, and audit (push) Successful in 6m46s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 17m32s
    Stable

    claude-bot released this 2026-09-06 02:20:47 +00:00 | 2488 commits to main since this release

    The first slice of v4.3.0, shipped early because three of these are things the Discord bot was telling your players that were not true. A failed summary is no longer announced as ready, every notification now links back to the session it is about, the bot no longer asks Discord for a permission it never used, and a campaign you archive stops scheduling sessions and pinging people about them. Plus three small web fixes: players can name their own character, wiki articles are readable on a phone, and an unreachable duplicate page is gone.

    No migration. The database schema is unchanged since v4.2.0 (e9fa0b1c2d3e remains the head).

    Bot API contract v1, unchanged. The notification payload gained an optional link field; an older bot ignores it and a newer bot renders without it. BOT_EXPECTED_APP_VERSION is bumped so the bot's startup log matches.

    One Discord setting you can now turn off. The bot no longer needs the "Message Content Intent" toggle in the Discord Developer Portal. Leaving it on is harmless; turning it off is one fewer privileged permission.

    No new settings. Nothing to add to .env.

    Fixed

    • [webapp] The wiki article page no longer squeezes the article text into a
      sliver on a phone
      (#376). The infobox now stacks below the article instead
      of sitting beside it below the lg breakpoint, so the article body gets the
      full screen width on narrow screens.

    • [webapp] Archiving a campaign now makes it go quiet (#479). Archiving was
      a display state and nothing more: it changed which list a campaign appeared
      in, and every scheduled task went on treating it as fully live. The root was
      not the reminders — it was session creation. The hourly materialiser selected
      active recurring series with no campaign check at all, so an archived campaign
      with a weekly game kept minting confirmed sessions 28 days ahead, every hour,
      and the reminder poller then correctly reminded a group that had stopped
      playing about every one of them. A campaign a GM had explicitly retired could
      therefore be the noisiest thing on a Discord server. Materialisation now skips
      archived campaigns (and trashed ones, which had the same hole), and the five
      notifications that fire with nobody in the loop — the session reminder, the
      at-risk warning, the three-day vote nudge, the recap email and the
      end-of-session attendance post — are suppressed for a campaign that is
      archived, each logging the campaign id once so an operator can see why nothing
      was sent. Nothing else changes: an archived campaign still transcribes and
      summarises its recordings, still writes journal entries, still warns before
      retention deletes its audio, and still answers a vote or a "reveal to Discord"
      that somebody actually asked for — the line is unprompted, not
      Discord-facing, because swallowing a button press would be a worse bug than
      the one being fixed. Future sessions that a series already materialised are
      kept, and archiving is never refused because of them: deleting them would
      destroy rows a group may have notes and attendance on, and refusing would make
      archiving fail on exactly the campaigns it exists for. They simply stop
      speaking, and the archive response now returns how many just went quiet.
      Un-archiving resumes everything, materialisation included.

    • [bot] A failed transcription no longer announces itself as "Session Summary
      Ready"
      (#372). When summarisation failed, the backend sent the same
      session_summarised event with an error instead of a summary, and the bot
      posted the success embed anyway: a green "Session Summary Ready" headline over
      the body "No summary was generated.", followed by 👍/⚠️/❌ reactions inviting
      the table to rate a summary that did not exist. The failure now gets its own
      red notice that names the session, says the recording was kept so it can be
      run again, and points a GM at the retry on the session page — with no feedback
      reactions, because there is nothing to rate. The underlying error goes to the
      bot's log rather than the channel.

    • [bot/webapp] Every Discord notification now links back to Quest Board
      (#391). Twelve of the thirteen notification embeds named a session and then
      left you to go and find it; only the vote reminder carried a link, and the
      summary embed's footer said "Full transcript available to GMs on Quest Board"
      without being clickable, because Discord embed footers cannot hold links. The
      backend now attaches the session's URL to every notification it sends, and
      each embed renders it as an "Open in Quest Board" field — the summary's
      signpost among them, moved out of the footer so it can actually be followed.
      Nothing changes for an instance with no APP_URL set: the field is omitted
      rather than filled with a link that goes nowhere.

    • [bot] The bot no longer asks Discord for the privileged Message Content
      intent
      (#394). It never read message text — every command is a slash
      command — but requesting the intent meant a self-hoster who did not tick
      "Message Content Intent" in the Discord Developer Portal got a bot that
      refused to connect and, under Compose's restart policy, restarted for ever.
      The intent is gone, so that toggle is no longer needed; Server Members
      Intent is still required
      and is what per-speaker recording attribution
      depends on. If a privileged intent ever is missing, the bot now says which
      one and where to enable it, once, and keeps serving /health instead of
      crash-looping. .env.example and the Discord setup docs have been corrected —
      they told you to enable Message Content and called Server Members optional,
      which was backwards.

    Internal (no operator-visible change)

    • Deleted the orphaned CampaignNotes page and its unused /campaigns/:id/notes
      route (#384). It was linked from nowhere and duplicated the campaign journal
      already shown on the campaign page itself; the "back to dashboard" pattern it
      was cited as an example of still exists in CampaignHighlights.jsx.
    • Added regression tests locking in that a player already sees an actionable
      "+ Add character" button on their own row when they have no character yet
      (#383). Investigation found the underlying behaviour was already fixed as a
      side effect of the multi-character work in #330; no production code changed.
    Downloads
  • v4.2.2 5d3fa863e6

    v4.2.2
    All checks were successful
    Release / Create Forgejo release (push) Successful in 12s
    CI / Bot/backend version sync (push) Successful in 45s
    CI / Backend lint (ruff) (push) Successful in 54s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m28s
    CI / Frontend tests, audit, and build (push) Successful in 1m55s
    CI / Bot tests and audit (push) Successful in 2m9s
    CI / Docker image build (push) Successful in 4m11s
    Release / Build and push versioned images (push) Successful in 4m25s
    CI / Backend migration, tests, and audit (push) Successful in 9m59s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m56s
    Stable

    claude-bot released this 2026-09-05 17:26:13 +00:00 | 2503 commits to main since this release

    v4.2.1's headline fix did not hold on the bundled stack, so this patch follows within the hour. The hardware preflight now measures a reasoning model's throughput from the tokens it actually produced, whether or not it finished its answer, and reports a profile band on the default self-hosted configuration.

    No migration. The database schema is unchanged since v4.2.0 (e9fa0b1c2d3e remains the head).

    Bot API contract v1, unchanged. BOT_EXPECTED_APP_VERSION is bumped so the bot's startup log matches.

    No new settings. If you deployed v4.2.1 and the preflight panel still said "Not measured" for the LLM, upgrade and re-run the preflight; that is the whole of the change. If you skipped v4.2.1, its notes still apply.

    Fixed

    • [webapp] The hardware preflight now measures a reasoning model whether or
      not it finishes its answer
      (#507). v4.2.1 gave a thinking model a bigger
      output budget on the theory that it would finish inside one. On the bundled
      llama.cpp stack serving qwen3.5 — the default self-hosted configuration — it
      did not: the model thought for the whole budget, 2048 tokens in 61 seconds,
      and the panel reported "Not measured" again. Raising the cap further is the
      wrong lever, because a budget large enough for a workstation GPU to finish
      inside is a timeout on a CPU box. So the probe stopped asking the model to
      finish. 2048 tokens in 61 seconds is 33.6 tokens per second, which is the
      only number the preflight exists to obtain, and it is now read off the
      generation that ran out of room instead of being thrown away with it. A
      deployment running a local reasoning model gets a generation rate, a profile
      band and an expected session time, with the panel saying that the rate came
      from a generation that never reached a visible answer. An endpoint that
      reports no tokens at all is still reported as broken.
    Downloads
  • v4.2.1 504b1d943e

    v4.2.1
    All checks were successful
    CI / Bot/backend version sync (push) Successful in 43s
    CI / Backend lint (ruff) (push) Successful in 54s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m25s
    CI / Frontend tests, audit, and build (push) Successful in 1m51s
    CI / Bot tests and audit (push) Successful in 2m9s
    CI / Docker image build (push) Successful in 4m30s
    Release / Build and push versioned images (push) Successful in 4m41s
    CI / Backend migration, tests, and audit (push) Successful in 9m24s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 15m14s
    Stable

    claude-bot released this 2026-09-05 16:26:30 +00:00 | 2510 commits to main since this release

    Superseded by v4.2.2. The preflight fix below did not hold on the bundled llama.cpp stack: a reasoning model thinks for as many tokens as it is given, so the larger budget still produced no visible answer and the panel still read "Not measured" (#507). v4.2.2 measures throughput from the tokens the model did produce instead. The other two fixes in this release are good; upgrade straight to v4.2.2.

    A patch on v4.2.0 from the first live validation of the release: the hardware preflight now measures reasoning models instead of reporting "Not measured", usage rows name the ASR model that actually served the run, and the synthetic-session harness scores its in-character assembly against the right denominator.

    No migration. The database schema is unchanged from v4.2.0 (e9fa0b1c2d3e remains the head).

    Bot API contract v1, unchanged. A v4.2.0 bot works with a v4.2.1 backend and vice versa; BOT_EXPECTED_APP_VERSION is bumped so the bot's startup log matches.

    No new settings. Nothing to add to .env. If your preflight panel showed "Not measured" for the LLM on a self-hosted reasoning model, re-run the preflight after upgrading; nothing else is required.

    Fixed

    • [webapp] The hardware preflight now measures deployments running a
      reasoning model, instead of reporting "Not measured"
      (#507). Admin → AI →
      "Run preflight" asked for at most 128 tokens of prose, which is not a short
      answer for a thinking model — it is no answer at all, because the hidden
      reasoning spends the whole budget before the first visible word. On the
      bundled llama.cpp stack serving qwen3.5, which is the default self-hosted
      configuration, the probe failed every time: no generation rate, no expected
      session time, and a "Not measured" label beside an endpoint that summarises
      whole sessions perfectly well. A provider that declares reasoning is now
      probed with a budget it can answer in, and one that turns out to need it
      anyway — an OpenAI o-series model, or Ollama serving qwen3 — is retried once
      with the larger budget, with the panel saying why that measurement took
      longer. The reported rate still counts the reasoning tokens, because a real
      summarisation run pays for them too.

    • [webapp] Admin → AI Usage now says which model transcribed a session
      (#508). If you run the bundled transcription server, the model column was
      empty on every run: the server picks its model at startup and takes no model
      parameter, so there was nothing configured to record — while the
      summarisation half happily named its model. That left the one question worth
      asking of a self-hosted box unanswerable, because the model does change
      under you when somebody redeploys it with a smaller one, and nothing in the
      history said when. Quest Board already asks the server what it is running
      (that is how it knows whether your server can do word timings); it now
      records the answer alongside the run and shows it beside the transcription
      time. A model you have set by hand still wins, an endpoint that never
      answered is recorded as unknown rather than blank, and a run on your own
      hardware is still costed in compute seconds rather than dollars. Older rows
      keep their empty column — the model a session used last month is not
      recoverable now.

    • [webapp] "No vocabulary hints" is now reported as a medium-impact gap, not
      a low one
      (#506). The capability panel ranked it below "the provider was
      guessed from the URL", which told operators the wrong thing to fix first: the
      measurement in v4.2.0 put the campaign vocabulary at roughly ten to
      twenty-five points of proper-noun recall on identical audio, at no cost to
      the rest of the words — the largest single effect anything in that release
      measured. The wording now says what you actually lose, which is your own
      characters' and places' names coming back mis-heard and spelled differently
      every time. Nothing about the pipeline changed; if you run the bundled
      transcription server, the fix is still to update it.

    Internal (no operator-visible change)

    • The synthetic-session harness scored marker detection for its ic_only
      assembly over the whole script's 54 marker occurrences, four of which are
      only ever spoken in out-of-character lines that assembly does not render —
      so that leg understated itself by counting markers no transcriber could have
      returned (#506). Each assembly is now scored against its own script, and the
      excluded occurrences are reported next to the count. full is unchanged.
      Committed calibration figures are annotated rather than restated: re-deriving
      them needs a rerun on the GPU box.
    • The same harness seeded its Discord links unverified, and attendance
      resolution deliberately ignores unclaimed links, so a live run reported all
      five known players as unmatched strangers and never reached the
      uncaptured-member check the fixture exists to exercise (#506). The links are
      seeded verified now, seed_db --verify checks that they are, and the
      expected attendance outcome of a live run is written down.
    Downloads
  • v4.2.0 f7aa15e457

    v4.2.0
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 27s
    CI / Bot/backend version sync (push) Successful in 22s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Bot tests and audit (push) Successful in 1m26s
    Release / Build and push versioned images (push) Successful in 46s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 2m1s
    CI / Frontend tests, audit, and build (push) Successful in 2m42s
    CI / Docker image build (push) Successful in 4m24s
    CI / Backend migration, tests, and audit (push) Successful in 9m55s
    CI / Synthetic session harness (no GPU, no LLM) (push) Successful in 14m20s
    Stable

    claude-bot released this 2026-09-05 06:22:55 +00:00 | 2527 commits to main since this release

    AI Provider Abstraction & BYO-AI. No commitment to any single transcription or
    language-model provider: a normalised contract with swappable adapters, a
    campaign can bring its own providers, self-hosting is a first-class, measured
    target on both GPU and CPU, and the pipeline tells you what your configuration
    cannot do instead of quietly working around it.

    ⚠️ Five migrations — a5b6c7d8e9fa (provenance on summary runs),
    b6c7d8e9fa0b (per-session usage and cost), c7d8e9fa0b1c (per-word timings
    on transcript segments), d8e9fa0b1c2d (per-campaign AI settings) and
    e9fa0b1c2d3e (resolved provider identity on usage rows). All are additive —
    new tables or nullable columns, nothing rewritten, nothing backfilled — and
    each was verified to apply, roll back and re-apply cleanly. For a minor
    upgrade this size take a snapshot first: make migrate-safe does the
    preflight, backup and migrate in one step.

    The bot contract is unchanged (still v1) and nothing under /api/bot/*
    changed shape, so the backend and bot images can be upgraded independently in
    either order.

    ⚠️ If you run the bundled transcription server, update it. The word
    timings and the campaign-vocabulary hint in this release need its API v2;
    against an older server Quest Board keeps working exactly as before and the
    new "What your AI configuration can and cannot do" panel tells you to update.
    The server lives outside this repository; see OPERATIONS → "Upgrading the
    bundled transcription server".

    Six new optional .env settings, all with safe defaults:
    QB_LLM_CONCURRENCY_LIMIT, QB_ASR_CONCURRENCY_LIMIT, QB_AI_PROFILE,
    QB_LLM_TIMEOUT_SECONDS, QB_LLM_PROSE_TIMEOUT_SECONDS,
    QB_ASR_TIMEOUT_SECONDS. Leave them unset unless OPERATIONS tells you
    otherwise; a CPU-only deployment should pin QB_AI_PROFILE=cpu.

    The repository now carries ~9 MB of invented audio — a committed
    synthetic 30-minute session that exercises transcription end to end in CI.
    No real voices, no real content; see CLAUDE.md for the carve-out.

    Added

    • [webapp] Each group can now bring its own AI provider, and pay its own
      bill
      (#128). One additive migration (d8e9fa0b1c2d) adds a new
      campaign_ai_settings table; nothing existing is touched, nothing is
      backfilled, and every campaign keeps using the instance's providers until
      someone changes that. Transcription and summarisation have always been one
      endpoint each for the whole instance, which is right when you host one group
      and awkward the moment you host several — "whose OpenAI bill is this" is a
      question those groups need to be able to answer separately. Admins get one new
      switch in Admin → Bot Settings, off by default: "Let campaigns bring their
      own AI providers"
      . With it on, each campaign's settings page grows a panel
      where its GM can point transcription and summarisation at their own endpoints
      and keys. The two sides are independent, so "our own key for summaries, your
      Whisper box for transcription" works; leaving either blank keeps using yours.
      Keys are encrypted at rest with the same key as your own instance secrets and
      are never shown again, to anyone. Every URL goes through the same checks an
      admin's does, so a GM cannot aim the instance at an address on your private
      network. Turning the switch back off makes every override on the instance
      inert immediately without deleting any of them — switch it on again and
      each group has exactly what it had. Two things worth knowing before you enable
      it: a group pointing at a hosted service is uploading their session audio to
      that service under their contract rather than yours, and a campaign whose own
      endpoint is misconfigured fails visibly rather than quietly borrowing yours.
      See "Per-campaign AI providers (bring your own)" in docs/OPERATIONS.md.

    • [webapp] Quote timestamps now point at the word, not at the sentence — if
      your transcription server is current
      (#352). One additive migration
      (c7d8e9fa0b1c) adds a nullable words column to transcript_segments;
      nothing existing is touched and nothing is backfilled. The bundled
      transcription server used to return one time per sentence, so a quote
      captioned "[01:12:40]" was really "somewhere in the twenty seconds that
      started at 01:12:40" — which is fine for a one-line reply and useless for a
      line thirty seconds into somebody's monologue. The current server aligns
      every word, and Quest Board now reads those timings and anchors each quote to
      the word it actually begins on. Alignment is not perfect and is not pretended
      to be: a word the server could not place is positioned between its neighbours
      and marked as an estimate, and a quote anchored to an estimated word keeps
      the old sentence-level timestamp rather than a prettier invented one.
      Because two versions of that server exist and you update it on your own
      schedule, Quest Board now asks it what it can do — one cached GET /health
      — instead of assuming. On an older server everything behaves exactly as it
      did before, with one deliberate change: it is now reported as taking no
      vocabulary hints, because it accepts the campaign-names field and silently
      ignores it, and Admin → Bot Settings tells you that updating the server is
      the fix. Word timings and vocabulary bias apply to new sessions only —
      nothing is reprocessed, and older transcripts keep their sentence-level
      times. See OPERATIONS.md → "Upgrading the bundled transcription server".

    • [webapp] A busy evening now makes you wait instead of making you retry
      (#356). Groups play on the same two evenings, so a week of recordings lands
      within a few hours of each other — against one transcriber that does one
      session at a time and one AI endpoint that does two requests at a time.
      Quest Board now knows those limits and queues behind them instead of pushing
      past them, so a Saturday backlog costs you some patience rather than a
      failed session with a rate-limit error in it. If a service turns a request
      away anyway — a shared GPU box, or a hosted provider's own limit — that
      session goes back in the queue and waits as long as the service asked,
      instead of failing with something you could not have fixed. While a session is waiting its
      page says so — "Queued behind 3 other sessions — about 20 minutes" — rather
      than showing the same silent "Processing…" a genuinely stuck session shows,
      which is what has been sending GMs to the Retry button and putting a second
      copy of the same recording into the same queue. /ask and the Test LLM
      button never wait: if the queue is full they say so immediately, and say
      that your endpoint is not the problem. Admins get the whole picture at
      GET /api/admin/ai/queue. The defaults suit the deployments this ships
      with; if you run llama.cpp with --parallel 4, or several transcription
      replicas behind one URL, two new optional settings say so —
      QB_LLM_CONCURRENCY_LIMIT and QB_ASR_CONCURRENCY_LIMIT in .env. No
      migration, and nothing to change if you leave them alone.

    • [webapp] Transcription is now tested end to end against a session that
      never happened
      (#433). Recording, transcribing and attributing a session is
      the part of Quest Board with the most ways to go quietly wrong, and until now
      it was only ever tested against mocks — nothing checked that a real speech
      server, given six speakers talking over each other for half an hour, gave
      every line back to the right person. There is now a complete invented session
      in the repository: six speaker tracks of computer-generated speech, thirty
      minutes, every word written for the test, and every check that guards your
      recordings watched actually firing against it. No real voices and no real
      session content are involved, and none ever will be. This costs the
      repository about 9 MB of audio; the reasoning for allowing that at all, and
      the rules it lives under, are in CLAUDE.md.

    • [webapp] Transcription is now told your campaign's names before it
      listens
      (#355). Your party's character names, your wiki's people, places,
      factions and artifacts, your NPC cards and any alternate names you have
      recorded are handed to the speech model as a hint at the start of every
      recording it transcribes. Invented names are the one thing a general-purpose
      speech model has never heard, and they are also the words that matter most in
      a session — and once a name is wrong in the transcript, nothing further down
      can fix it, because the summary, the highlights and the wiki suggestions all
      read the transcript rather than the audio. You should see fewer sessions
      where a character's name is spelled three different ways, or split into two
      ordinary words. Nothing to configure, and nothing changes for a campaign with
      an empty wiki. The list is rebuilt from your wiki each time a recording is
      processed, so a name you add tonight is in effect for tonight's session, and
      a name you retire stops being suggested. Entries in the wiki trash and
      suggestions the bot has made but you have not accepted are deliberately left
      out.

    • [webapp] Admin → Bot Settings now tells you what your AI providers cannot
      do, instead of quietly working around it
      (#358). One additive migration
      (a5b6c7d8e9fa) adds a nullable column to summarisation_runs; nothing is
      backfilled and nothing existing changes. A new panel, "What your AI
      configuration can and cannot do", names the resolved provider on each side and
      lists every gap in plain language with the consequence you would otherwise
      just experience: an endpoint that cannot enforce a JSON schema (the big one —
      events can be dropped or mis-attributed from a summary with nothing failing),
      a context window too small to hold a session in one prompt and how many passes
      it will take instead, a provider that does not report prompt tokens so an
      over-long transcript cannot be detected, an endpoint that silently discards
      the start of an over-long prompt, a provider guessed from its URL rather
      than configured, and transcription without word-level timestamps so quote
      timings are estimated. The "Test connection" buttons report the same thing
      alongside reachability, because an endpoint can answer in 300 ms and still be
      unable to do any of this. Quest Board has known all of it since v4.1.1 and
      told nobody, which meant a limitation of the endpoint you picked read as a
      limitation of Quest Board. Summaries also now record the gaps that applied
      when they were written — shown on the session as "Summarised under a limited
      configuration" — and a run on a provider that cannot enforce a schema is
      marked degraded up front rather than only when its output came back visibly
      broken.

    • [webapp] You can now have OpenAI or Groq transcribe your sessions instead
      of the Whisper server you run yourself
      (#128, #350). No migration, and
      nothing changes unless you choose it — the bundled server stays the default
      and stays a fully tested path. Admin → Bot Settings gains a transcription
      provider select and a model field alongside the endpoint URL; point it at
      https://api.groq.com/openai/v1 or https://api.openai.com/v1 and leave the
      model blank for that host's default, or name any other server that speaks
      the same API. Read this part before you switch: with a managed provider,
      every per-speaker track of every recording is uploaded to a third party.
      If
      you self-host Quest Board precisely so your table's recordings stay on your
      own machine, this setting undoes that and no amount of accuracy is worth it.
      If that is not why you self-host, what you get is worth having: word-level
      timings that are measured rather than estimated across each sentence, so
      jumping to a quote lands on the word rather than near it; no GPU to own or
      keep running; and a three-hour session transcribed in minutes on Groq's turbo
      model. The "What your AI configuration can and cannot do" panel makes the
      difference concrete — the bundled server lists two gaps there and this lists
      none. Both services cap one request at 25 MB, which is under fourteen minutes
      of audio, so tracks are cut into speech spans and stitched back onto the
      session timeline automatically — including when you have silence trimming
      switched off, because otherwise every track would be refused unsent. A chunk
      that is refused anyway is counted, and a track that loses more than a quarter
      of itself fails the run rather than handing you a transcript quietly missing
      a third of the evening. See OPERATIONS.md → "Choosing a transcription
      provider".

    • [webapp] Quest Board now checks its own AI providers once a month, so a
      dead endpoint is found before a session needs it
      (#359). Every AI call in
      the product happens because a GM asked for something, which means a Whisper
      server that stopped answering in April is discovered by a failed
      transcription in July — with the recording already made. A new monthly
      background task transcribes one second of silence through whichever ASR you
      have configured and asks your LLM to reply with "OK", then records
      {ran_at, asr_ok, llm_ok, asr_provider, llm_provider, error} under the
      provider_canary key in app_settings, logging a warning when either side
      fails. It costs a second of audio and a handful of tokens a month; it grades
      nothing, retries nothing, and treats "not configured" as a blank rather than
      a fault. There is no admin panel for it yet — read it with the psql
      one-liner in OPERATIONS.md → Alerting. Alongside it, every AI provider
      adapter now has to pass a shared conformance suite in CI, which is what keeps
      "swap your AI provider without a rewrite" true as the code moves, and keeps
      the self-hosted fallback a tested path rather than a hopeful one.

    • [webapp] Quest Board now records what each session costs to process, in
      dollars or in GPU minutes depending on what you run
      (#357). One additive
      migration
      (b6c7d8e9fa0b) adds a new session_usage table; nothing
      existing is touched and nothing is backfilled, because there is nothing to
      backfill from — none of this was ever recorded. A new panel in Admin → Bot
      Settings shows the last 30 days by campaign, by month, and run by run: how
      many hours of per-speaker audio went in, how much of it the silence pre-pass
      removed before transcription, how long each phase took, how many tokens the
      model read and wrote, and what it is estimated to have cost. Every processing
      run gets a row, including the ones that failed — transcription is the
      expensive half and it finishes long before most failures do, so a run that
      transcribed three hours and then fell over has already been paid for, and a
      retry is a second row rather than a replacement for the first. Dollar
      figures are estimates from published list rates on a stated date
      , marked as
      such on every number: no provider tells us what it actually charged, and a
      model our rate table has never heard of gets no figure rather than a
      plausible-looking guess. If you self-host your transcription or your LLM there
      is no bill to estimate, so those runs show the compute time they occupied
      instead — which is the number to size a GPU against, and the honest answer
      where "$0.00" would imply the run was free. Nothing to configure; it starts
      recording on the next session you process.

    • [webapp] Admin → Bot Settings can now measure your hardware, and the docs
      say what to expect from three named profiles
      (#353, #354). No migration.
      A new panel, "Hardware profile", has a Run preflight button: it makes one
      health round trip to your transcription endpoint and one short generation
      against your LLM, times them, and reports what it found — a detected profile,
      tokens per second, transcription speed relative to real time, the gaps in your
      configuration, and roughly how long a 3.5-hour, five-player session will
      take on your hardware
      . Until now the only way to learn that was to record
      one and wait. The result is stored with the date it was taken, so the panel
      says "as measured on …" rather than pretending to be live, and the
      transcription speed is read from sessions you have actually processed rather
      than from a synthetic clip — a deployment that has processed nothing sees
      "not yet measured" instead of an invented number. POST and GET /api/admin/ai/preflight are the same thing as JSON. docs/OPERATIONS.md →
      "Hardware profiles" now carries a table of three profiles — CPU-only, modest
      GPU, workstation-class GPU — with the model, the hardware, the expected
      processing time and the measured accuracy scores for each, including the
      command that produced every number, plus
      docs/examples/docker-compose.cpu-llm.yml: a complete, runnable CPU-only LLM
      service to drop into the stack if you have no GPU.

    Changed

    • [webapp] Running without a GPU is now a supported configuration rather than
      a slower version of one
      (#353). No migration. Every timeout in the AI
      pipeline used to be a fixed number chosen against a GPU — five minutes for a
      structured call, ten for a transcription track, thirty seconds for the "Test
      connection" button. On CPU-only hardware those are not slow budgets, they are
      impossible ones, so a self-hoster without a GPU saw failures rather than
      patience, with a recording already made and nothing naming the cause. A new
      QB_AI_PROFILE setting (auto, gpu or cpu) scales all of them together,
      and QB_LLM_TIMEOUT_SECONDS, QB_LLM_PROSE_TIMEOUT_SECONDS and
      QB_ASR_TIMEOUT_SECONDS set any of the three directly if you have measured
      your own endpoint — a value you write down is used exactly as written. The
      Celery time limits are derived from those timeouts rather than configured
      beside them, so the limit can never be shorter than the provider calls it is
      supposed to contain. Nothing changes for an existing deployment: the
      default is auto, which is the GPU timings unless the new preflight has
      measured your box as CPU-class, and on the GPU profile the task limits are the
      same 6 hours they have always been. Two smaller consequences worth knowing:
      the monthly provider health check and the admin "Test connection" button now
      scale too, because a thirty-second cap on a CPU endpoint could only ever
      report a working provider as broken.

    Fixed

    • [webapp] A paid transcription service was being reported as free compute
      (#502). One additive migration (e9fa0b1c2d3e) adds three nullable
      columns to session_usage; nothing existing is touched and nothing is
      backfilled. Admin → AI Usage recorded the provider you configured rather
      than the one that ran — and "Detect from the URL" is the default, which
      stores nothing at all. So if you had pasted an OpenAI or Groq endpoint and
      left that select alone, every one of those runs was filed as self-hosted:
      the page showed GPU seconds and no dollars for sessions you were being
      invoiced for, which is the exact opposite of what it is for. Runs now record
      the transcription service, model and endpoint they actually used, so a
      managed one is counted as managed from here on. Older rows are left as they
      are — the endpoint a session used six weeks ago is not recoverable now, and
      guessing it would be inventing history rather than repairing it. Alongside
      that: managed transcription can be priced at all for the first time (OpenAI
      and Groq list rates, per model — the Groq figure is a floor, because their
      ten-second minimum per request is not visible from what is recorded), the
      rate table has learned the Claude 5 models the documentation recommends
      instead of showing no cost for them, and make conformance-live can now be
      pointed at a managed transcription service rather than only at the bundled
      one.
    Downloads
  • v4.1.2 bbf25d9c67

    v4.1.2
    All checks were successful
    Release / Create Forgejo release (push) Successful in 13s
    CI / Bot/backend version sync (push) Successful in 43s
    CI / Backend lint (ruff) (push) Successful in 51s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m17s
    CI / Frontend tests, audit, and build (push) Successful in 1m54s
    CI / Bot tests and audit (push) Successful in 2m12s
    CI / Docker image build (push) Successful in 4m51s
    Release / Build and push versioned images (push) Successful in 4m40s
    CI / Backend migration, tests, and audit (push) Successful in 7m13s
    Stable

    claude-bot released this 2026-09-05 01:25:00 +00:00 | 2628 commits to main since this release

    The AI provider you chose in v4.1.1 is now honoured everywhere, including the
    session summary itself. Upgrade straight past v4.1.1 if you have not already.

    No migrations. Nothing to rehearse, nothing to back out.

    The bot contract is unchanged (still v1), and nothing under /api/bot/*
    changed at all, so backend and bot images can be upgraded independently in
    either order.

    Under the hood every generation path now carries the configured LLM as one
    object instead of five loose parameters (#484). That is invisible to operators
    and changes no behaviour; it is what made the fix below a one-place change.

    Fixed

    • [webapp] The AI provider you chose is now used for session summaries and
      for the Test button, not just for everything else
      (#488). The provider
      select added in v4.1.1 was honoured when generating structured content and
      answering /ask, but two paths went on detecting the provider from the
      endpoint URL and ignoring your choice: writing the session summary itself,
      and Admin → Bot Settings' "Test LLM" button. This only affects you if you set
      the provider to something the URL does not already give away — typically a
      gateway or reverse proxy on your own domain — in which case summaries were
      being sent to the wrong service with credentials in the wrong header, usually
      failing outright, and the Test button reported on a connection nothing else
      in the product would have made. Nothing changes if you left the provider on
      "Detect from the URL".
    Downloads