• v4.1.0 68ec94ffef

    v4.1.0
    All checks were successful
    CI / Backend lint (ruff) (push) Successful in 46s
    CI / Docker image build (push) Successful in 9s
    Release / Create Forgejo release (push) Successful in 12s
    CI / Bot/backend version sync (push) Successful in 41s
    CI / Summarisation accuracy eval harness (stub provider) (push) Successful in 1m42s
    Release / Build and push versioned images (push) Successful in 54s
    CI / Frontend tests, audit, and build (push) Successful in 2m15s
    CI / Bot tests and audit (push) Successful in 2m32s
    CI / Backend migration, tests, and audit (push) Successful in 7m37s
    Stable

    claude-bot released this 2026-09-01 18:11:30 +00:00 | 336 commits to main since this release

    Data Durability & Recovery. Nothing you have made can be destroyed by a single
    click, a crashed worker, or a background job you never saw run.

    ⚠️ Two migrationse3f4a5b6c7d9 (wiki entry soft delete) and
    f4a5b6c7d8ea (campaign soft delete). Both add nullable columns and partial
    indexes only; no data is rewritten, and both were verified to apply, roll back
    and re-apply cleanly. For a major upgrade take a snapshot first — make migrate-safe does the preflight, backup and migrate in one step.

    The bot contract is unchanged (still v1), so you can upgrade the backend and
    bot images independently if you need to. Everything added to /api/bot/* this
    release is optional on both sides.

    ⚠️ Deleted things now occupy disk for 30 days. Campaigns and wiki entries go
    to a trash rather than being removed immediately, and are purged by a daily job
    once the grace period elapses. The window is enforced with a 7-day floor in
    code — configuring a shorter one is clamped up, deliberately, because these are
    artefacts nobody can reconstruct.

    New optional setting: RESTART_POLICY in .env. Leave it unset unless
    something else on your host owns boot ordering; see .env.example.

    Added

    • [webapp] The container restart policy can now be set per host. A new
      optional RESTART_POLICY in .env. Left unset it behaves exactly as before
      — the stack comes back on its own after a reboot — so no existing install
      needs to change anything.

      Set it to on-failure only if something else on your host is responsible for
      starting stacks in order, such as a boot sequencer pacing many Compose stacks
      across shared disks. It still restarts containers that crash; it only stops
      Docker bringing the stack up by itself when the daemon starts.

    • [webapp] Deleting a campaign can now be undone, and is much harder to do by
      accident.
      Deleting a campaign removed everything in it — every session,
      transcript, summary, wiki entry, note and ledger entry — immediately and
      permanently, and the only thing standing in the way was a browser dialog
      that any GM on the campaign could dismiss on reflex. There was no undo, no
      pause, and nothing suggesting you take a copy first.

      Deleting now asks you to type the campaign's name, offers to export the
      campaign before you go ahead, and puts it in a trash for 30 days rather than
      destroying it. Deleted campaigns appear at the bottom of your dashboard
      with a countdown, and restoring one brings back everything exactly as it was.
      Nothing is deleted for real until the 30 days are up.

      Archiving is unchanged and still means what it always did — a campaign moved
      out of the active list that you can still read, browse and export.

    • [webapp] Deleting a wiki entry can now be undone. Deleting an entry used
      to remove it permanently and immediately — along with its entire edit
      history, which went in the same instant. A GM who deleted what looked like a
      duplicate, and realised afterwards it was the real entry with months of lore
      in it, had nothing at all to go back to.

      Deleted entries now sit in a trash for 30 days, where a GM can put them back
      with everything intact. Admin → your campaign's wiki trash lists what is
      in there and when each one stops being recoverable.

    • [webapp] A wiki entry can be rolled back to an earlier version. The edit
      history has always been visible and never usable: recovering an older draft
      meant copying it out of the history view and pasting it back in by hand, and
      the sidebar and timeline fields could not realistically be restored at all.
      There is now a restore button on each version.

      Restoring saves the current state first, so if you restore the wrong version
      you can simply restore your way back.

    Changed

    • [bot] Recordings now take a sixth of the disk space they used to. The bot
      was storing audio in Discord's format and converting it after the session
      ended. It now converts as it records, so a six-hour session occupies about
      0.7 GB per speaker instead of 4.2 GB, and there is no conversion step to wait
      through when you stop recording.

      Audio quality is unchanged — the conversion filters the audio properly on the
      way down, the same as the old post-processing step did. Recordings made by an
      older version are still readable, and are converted the way they always were.

    Added

    • [webapp] Backups can now leave the machine they are protecting. Until now
      the scheduled backup wrote a database dump to a folder on the same host and
      disk as the database itself. Losing that host — a failed disk, a
      deprovisioned server, ransomware — took the database, the recordings, and
      every backup of the database at the same moment.

      Set an off-host destination in Admin → Backups and each backup is copied
      there as well. It uses rclone, so the same setting works for S3, Backblaze,
      a NAS over SFTP, and most other storage; your storage credentials live in
      rclone's own configuration and are never stored by Quest Board.

      A backup that cannot be copied off-host is now reported as failed, even
      though the dump itself worked. A backup sitting on the disk it was supposed
      to protect is not a backup, and calling it a success is how an instance finds
      out it has none at the worst possible moment. The local copy is still kept.

    • [webapp] Recordings can be included in backups. A database dump cannot
      contain audio files. Switch on Include audio recordings and each backup
      gains a compressed archive of them.

      It is off by default because recordings are far larger than a dump — check
      your disk space before enabling it. But if you keep recordings at all, you
      probably want it: a session whose transcription failed has no transcript, so
      its audio is the only record that the session happened.

    Fixed

    • [webapp] Granting admin by email no longer crashes when you have two
      accounts.
      Accounts are identified by which login provider you came from,
      not by your email address, so the same person can hold more than one — most
      commonly after copying a production database onto a test machine, where your
      test login is a separate account that was never made an admin.

      make set-admin EMAIL=… used to fail with a raw database error in exactly
      that situation. It now lists the matching accounts, shows which one already
      has admin, and tells you how to pick the right one. Nothing is changed until
      you do. The operations guide has a section on this, since it looks like lost
      access when it is not.

    • [docs] The operations guide told you to leave recordings out of your
      backups.
      It described the audio volume as scratch space that "does not need
      to survive a restore". That stopped being true some time ago — recordings
      stay until a GM approves them, a campaign can be set to keep them forever,
      and a failed transcription leaves the audio as the only copy of anything.
      Anyone who followed that advice would have lost every recording on the
      instance during a restore. The guide now describes what the volume actually
      holds, and how to back it up and restore it.

    Changed

    • [webapp] Build tooling: @vitejs/plugin-react 6.0.5 → 6.1.1. Purely a
      build-time dependency; nothing about the app itself changes.

      The update had been stuck since it was first offered, because 6.1.1 declares
      an optional peer whose own optional peer wants Babel 8 while the project is
      on Babel 7 — enough for npm to refuse the update even though none of those
      packages are actually installed. A single overrides entry stops npm chasing
      that chain, which unblocks it without dragging in 61 unrelated dependency
      bumps as the alternative would have.

    Fixed

    • [bot] A recording interrupted by a crash now finishes itself on the next
      start.
      If the bot went down mid-session, the audio it had already captured
      was left as raw working files, and there was no way to get it to Quest Board
      short of someone converting it by hand — the earlier fix stopped that audio
      being deleted, but stopped there. A recording that was interrupted an hour
      into a four-hour session sat on disk indefinitely.

      On its next start the bot now converts what it captured and hands it over for
      transcription, and the session appears as normal. Nothing is reconstructed:
      the tracks were already recorded against a single session clock, so the
      timings and who-said-what are the real ones. Speakers are named the way they
      are on any other recording — server nickname first, so a player whose
      nickname is their character's name still shows up as that character.

      It errs towards leaving the audio alone. If it cannot reach Quest Board, or
      the session's group has no Discord server attached, it changes nothing and
      tries again next start. If the session already has a transcript it keeps the
      take but will not overwrite what is there.

    Added

    • Recordings left behind by a deleted campaign are now reclaimed. Deleting
      a campaign removed its sessions but left every recording on disk, and nothing
      ever cleaned them up — one sweep only looks at sessions that still exist, and
      the other treats a handed-off recording as permanently off-limits. On a busy
      instance that is storage that only ever grows.

      A daily job now removes recording folders that no session refers to, after
      giving each a week's grace, and records what it removed in the audit log.

      It is deliberately cautious about it: it only ever touches folders it
      recognises as its own, it leaves anything recent alone, and if the session
      list comes back empty it refuses to do anything at all — an instance with
      recordings on disk and no sessions has a database problem, and treating that
      as "everything is an orphan" would delete every recording on the volume.

    Added

    • The bot event queue's size is configurable, and reports whether it is big
      enough.
      Notifications for the bot wait in a queue capped at 10,000 — enough
      for one group, but every group on an instance shares it. A bot down long
      enough for its unread notifications to age out loses them; the queue is what
      makes delivery reliable, so there is no catching up afterwards.

      BOT_EVENTS_MAXLEN now sets that cap, and a new
      bot_events_trimmed_unread_total metric reports whether notifications are
      actually being lost — so the number can be sized from evidence instead of
      guessed at. It reads nothing rather than zero when there is not yet enough
      data to tell, since "nothing to measure" should not look like "nothing
      wrong".

    Fixed

    • Unsaved writing is much harder to lose. Three surfaces could discard
      work with no warning:

      • The Session Shelf's tonight-notes — the field you type into during
        play — only saved when it lost focus. Closing the tab with the cursor
        still in it lost everything since the last time you clicked away. It now
        saves as you type, a moment after you stop.
      • The Beat Planner discarded your draft when you switched sessions in
        its own dropdown. It now asks first.
      • Session summary and transcript editors, the Beat Planner and the Shelf
        all now warn before a tab close or reload with unsaved text.

      Note that the browser shows its own generic "Leave site?" wording for that
      warning — it does not let an app supply the message. Warning on in-app
      navigation needs a change to how the app routes pages and is tracked
      separately.

    Fixed

    • [bot] A vote that doesn't reach Quest Board now tells you. Reacting to a
      poll in Discord leaves your emoji on the message whether or not the vote was
      actually recorded — so a brief backend outage during a voting window meant
      several people believed they had voted when they hadn't. Voting would then
      close on the tally that happened to arrive, confirm a time, and nobody would
      ever learn a vote had gone missing.

      A reaction that fails to register now gets you a DM saying so and what to do,
      while there is still time to fix it. If your DMs are closed, the bot marks the
      message with instead. Successful reactions stay silent.

      The same applies to RSVPs and summary feedback, and to reactions on a poll
      Quest Board has stopped tracking (an old poll, or a bot restart) — previously
      indistinguishable from reacting to any other message, and equally silent.

    Fixed

    • A departing member's shared notes are kept when someone else has already
      left.
      Deleting an account anonymises what you wrote onto a shared "Deleted
      user" record. If a second person later deleted their account and had written
      on the same session, their note collided with the first one and was thrown
      away instead — so the campaign quietly lost one person's writing, in a way
      indistinguishable from it never having been written.

      Both accounts' notes are now kept together on that session.

      Votes and attendance still cannot be merged — one vote per person is the
      point of the rule, and there is only ever one "Deleted user" — so a second
      departure genuinely does reduce those tallies by one. That was already true;
      what is new is that it is now counted and recorded in the audit log rather
      than happening silently.

    Fixed

    • The campaign journal works for campaigns with more than one GM. It
      errored outright for every member of a co-GM campaign — the page looked as
      though everyone's notes had vanished, when in fact it was crashing while
      loading them. Co-GMs are supported everywhere else in the app; this one query
      assumed there was exactly one.

      Both GMs' public notes now appear, each labelled with who wrote it, so two
      people's notes under one "GM Notes" heading no longer read as one person
      contradicting themselves.

    Fixed

    • A queue hiccup no longer leaves lore generation stuck at "queued…". The
      session was marked queued before the job was actually handed to the queue, so
      a momentary blip left it claiming work that nobody would ever pick up — and
      because it looked busy, re-running it was refused. The failure is now
      reported straight away, as a failure, with the re-run available immediately
      rather than after a wait.

    Added

    • You are told before a recording is deleted for good. Audio reaches
      deletion through the trash, and until now nothing announced that the clock
      was running — the countdown existed only in the admin console, so noticing
      required knowing to go and look there.

      Quest Board now posts to your campaign's Discord channel a few days out,
      saying which recording, exactly when, and how to keep it. Once per trip
      through the trash, not daily: a message that arrives every morning is one you
      stop reading. The same countdown appears on the session page itself.

      It says explicitly when a session's transcription failed, because that is
      the case where the recording is the only copy of anything — no transcript was
      ever produced — and losing it loses the session rather than just its media.

    Fixed

    • "Append to beat notes" no longer wipes them. The Workbench's session-prep
      tool composed its append against a field the campaign session list has never
      returned, so the existing notes always read as empty — the generated sheet
      replaced whatever the GM had written, and the tool reported "Appended to
      beat notes."
      A GM with an evening of hand-written prep lost it, and was told
      the opposite. There is no undo for beat notes.

      The same root cause left the beat-notes editor opening blank no matter
      what was stored, so typing there and saving replaced the stored notes too.

      Beat notes now have a read path and both places use it. If the current notes
      cannot be loaded, appending fails and says so rather than composing against
      nothing — a button labelled Append must never be able to replace. The notes
      stay GM-only: the read is gated the same way the transcript is, so a player
      cannot see the GM's plan for the evening.

    • Nothing the app generates will overwrite what you wrote. Two paths did,
      both silently, and neither keeps history — so the work was not merely
      replaced, it was gone.

      The campaign storyline was rebuilt from raw session summaries every time
      any session in the campaign was transcribed, re-transcribed, or had its
      summary touched. An hour spent turning AI summaries into a readable chronicle
      survived until the next recording. A hand-edited storyline is now left alone;
      the "Regenerate" button still rebuilds it, because that is you asking.

      Picking a canonical name — settling what to call a tavern — re-summarised
      the whole session and replaced the summary with it. A GM who had corrected
      the AI's mistakes by hand got those mistakes back, from an action that only
      claimed to name something. Naming no longer touches the summary at all; the
      name still applies to everything generated afterwards, which is what it was
      for.

      The two compounded: one canonical-name pick could destroy a corrected summary
      and the campaign chronicle, because the first rewrite triggered the second.

    Fixed

    • Two people editing the same thing no longer silently erase each other.
      Notes, summaries and planning notes were saved last-write-wins with no check
      of any kind, so whoever clicked Save second won and the other edit was gone —
      no error, no warning, nothing in a log.

      The case that actually bit: you open a session on a laptop, type notes from
      your phone over Discord all evening (which correctly appends them), then click
      Save on the laptop out of habit at the end of the night. The laptop still held
      the note as it looked before the session, and saving replaced the whole
      evening with it.

      Saves now carry the version they were based on. If the thing changed
      underneath you, the save is refused and you are told to reload rather than
      finding out later that something is missing. Nothing is auto-merged and
      nothing you typed is thrown away — you choose what to keep.

      Appending instead of replacing was the other option and is worse for a text
      editor: fixing a typo would duplicate the whole note rather than correct it.
      Refusing the write loses nothing either way.

    Added

    • Four actions that could only be taken once can now be taken back.
      Marking a session complete, cancelling one, approving a summary and having a
      wiki draft stall were all one-way doors: no endpoint could reverse them, so a
      mis-click or a lost worker left editing the database by hand as the only
      repair.

      • Undo complete and Reinstate return a session to where it was. A
        cancelled session goes back to confirmed if it still holds a time and to
        proposed if it does not. Both are recorded in the audit log, because they
        rewrite something players and Discord have already seen.
      • Withdraw approval lets a GM correct a summary and publish the
        correction. Approving is what posts the summary to Discord, and it could
        only happen once — so an error spotted after approval was permanent in the
        channel no matter how many times the text was fixed in the app. Quotes are
        unpublished from players while the approval is withdrawn, mirroring what
        approving did.
      • A wiki draft stuck generating can be released without discarding it.
        Discarding was the only exit and it deleted the draft outright, taking
        every inline edit and every round of iteration with it. Releasing marks the
        draft failed and changes nothing else, which is enough — iterating,
        approving and discarding are all reachable from there. Guarded so a draft
        that may still be running is left alone.

      What none of these do is pretend the side effects never happened. A Discord
      message already delivered is not recalled, shelf notes already merged into a
      wiki entry's GM notes stay merged, and withdrawing an approval does not pull
      the audio back out of the trash — that grace window is deliberately bounded
      (see the retention change in v4.0.1) and an approve/withdraw cycle must not
      be able to extend it.

    Fixed

    • [webapp] A stalled lore run no longer hides its own retry button. The
      re-run action was hidden for every in-progress state, so a run whose worker
      died left the session showing "Extracting lore candidates…" with nothing to
      click. Once a run has gone half an hour without any sign of progress, the
      action comes back, labelled so it is clear the run is being replaced rather
      than queued alongside. A run that is merely slow keeps reporting progress at
      each stage and is left alone.

      Closing the same hole from the other side: the endpoint behind that button
      refused a second run only while the first was queued, not while it was
      extracting or matching. Triggering it during either of those stages started a
      second extraction over the same transcript — duplicate proposals, and
      double the tokens. Nothing hit it in practice only because the button was
      hidden, which is exactly the rule being relaxed here, so both halves ship
      together.

    • [webapp] A restart during processing no longer strands a session forever.
      Celery acknowledged a job the moment it was received, so a worker that died
      mid-run took the job with it — nothing was redelivered and nothing was logged.
      The session had already been flipped to "Processing…" on intake, and both
      retry buttons refuse to act on a session in that state, so the only way out
      was an UPDATE against the database by hand. An ordinary
      docker compose up -d --build during a three-hour transcription was enough to
      cause it.

      Work is now acknowledged on completion, so the queue redelivers whatever a
      dying worker was holding. Two safeguards ship with it, because redelivery
      alone would turn a job that kills its worker into a job that kills every
      worker in turn: a six-hour limit (above the six-hour recording maximum, so a
      legitimate full-length transcription is never cut off) converts that into an
      ordinary failure, and each worker now reserves one long job at a time rather
      than a batch.

      An hourly watchdog is the backstop for anything redelivery cannot reach.
      A job with no progress for eight hours is marked failed with a plain
      explanation, which puts the retry button back within reach; audio, lore
      extraction, entry drafts and Workbench generations are all covered. Nothing is
      deleted, the audio path is left intact, and a job still legitimately running is
      left alone. Transient network failures during transcription now retry twice on
      their own before any of this applies.

    • [bot] The bot no longer deletes recordings it told you were saved. Its
      startup cleanup removed any session directory that was unmarked and more than
      an hour old, and the "this is safe" marker was only written after a
      successful upload. So a failed upload, a crash around the moment of upload, a
      crash mid-recording, or the backend declining to overwrite an existing
      transcript all left a real recording looking like abandoned scratch — and the
      next restart destroyed it. In two of those cases the bot had just told the GM
      in the channel that the recording was safe.

      Deletion now requires proof there is nothing to lose: anything holding a
      non-empty track is kept, marked or not, and reported in the log instead. A
      recording whose upload failed is also retried automatically on the next
      start
      , replaying the original request rather than a reconstruction, so it
      usually repairs itself without anyone noticing.

      Raw capture files from a crash mid-session are likewise kept rather than
      deleted. They still need finalising by hand — the log names the path — but
      they are no longer thrown away.

    Changed

    • Scheduled database backups are now on by default, and land somewhere you
      can see.
      They shipped disabled, which meant an instance only had backups if
      its operator went looking for the setting — production ran for months with no
      scheduled dump ever taken. They now run nightly out of the box, writing to
      ./backups beside your docker-compose.yml instead of a Docker named
      volume buried under /var/lib/docker. That is the difference between a file
      your own backup tooling already sweeps up and one it never sees. An install
      that has deliberately turned backups off keeps that choice.

      Upgrading: your existing dumps are still in the quest-board_backups
      volume. Nothing moves or deletes them — docs/OPERATIONS.md has a one-liner
      to copy them across when you are ready.

      Still not covered: the media and audio volumes, and any off-host copy. A
      backup on the same disk survives a bad migration, not a dead machine. Point
      your own tooling at ./backups in the meantime.

    Downloads