Skip to content

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog.

[Unreleased]

Changed

  • Correct the CI dependency-install count in the project configuration comment (#1273).

[2.43.18] - 2026-10-13

Fixed

  • Upload fresh emoji, server icons and banners when their cached Autumn IDs may already be attached (#1187).
  • Apply the Stoat instance's published upload limits for the account's tier, with the current limits as a fallback when unavailable (#1209).
  • Reuse exporter media only after a completed export into the same folder, and clear the completion flag before another attempt starts (#1223).
  • Check whether the Stoat instance enables its voice service before probing voice, and report unavailable feature information without claiming voice is disabled (#1210).

Changed

  • Describe voice as an instance capability and replace retired Autumn source citations with Stoat v0.15.7 references (#1210, #1261).

[2.43.17] - 2026-10-12

Fixed

  • Read thread parents from exported channel IDs, preserving filename fallback when the JSON lacks a parent ID (#1216).
  • Reject duplicate channel IDs before migration sends messages, name both export files, and ask the user to remove the older file (#1224).
  • Stream export metadata without retaining the message array, preserving normal parsing and preventing unknown JSON keys from replacing channel or guild IDs (#1244).
  • Ignore environment and .env Discord tokens for offline exports with a note naming DISCORD_TOKEN, while preserving explicit flag conflicts (#1197).
  • Report unusable HTTP and HTTPS proxy logins without exposing credentials or changing proxy selection (#1234).
  • Keep one browser migration running per Ferry process across page disconnects, block competing starts, and preserve pending resume prompts through temporary reconnects (#1237).

Changed

  • Describe local thread archive files and their output paths, correct export parsing documentation, and document HTTP 409 receipt replays after contact-key rotation (#1215, #1296).

[2.43.16] - 2026-10-12

Fixed

  • Create capped Voice channels in one request and preserve voice caps through blueprint export, import and build, with an optional offline metadata folder in both interfaces (#1246).

[2.43.15] - 2026-10-12

Fixed

  • Cancel Discord exports while the exporter is waiting for output, including an overlong line, and stop the child before cancellation returns (#1232).
  • Reject invalid cached exporter executables, prepare downloads in a temporary directory, and publish the executable only after its permissions are ready (#1289).
  • Name empty feedback timeouts and show the service's retry time in both interfaces (#1242).
  • Return JSON 400 errors for oversized integers, lone Unicode surrogates, and double-@ URL userinfo in feedback requests (#1238).
  • Link replies only within the same Stoat destination channel, name other channels in text fallbacks, and count only native replies as linked (#1230).

Changed

  • Raise the aiohttp, NiceGUI, PyJWT, and certifi dependency floors, and upgrade only uvicorn and its required HTTP parser to remove the two test warnings (#1175, #1274).
  • Pin the feedback image to Python 3.11.17, add Docker dependency updates, run packaging checks for dependency-only pull requests, and lock the docs dependency install (#1275, #1276, #1277).
  • Use GitHub's compatible 2026-03-10 REST API version for feedback (#1278).
  • Correct the pinned-search rate bucket and macOS proxy docstrings, document Stoat 0.15.7 permission resolution, and distinguish parsed export fields from delivered content (#1266, #1293, #1272, #1270).

[2.43.14] - 2026-10-12

Fixed

  • Suppress migration notifications with Stoat's message flag, remap embed titles, descriptions and fields, and omit local author icon paths (#1205, #1211, #1212).
  • Recognize typed upload-size failures and server limits, and require matching evidence in deep upload probes (#1258).
  • Reject empty files and corrupt Pillow-supported images before upload on every tag, bundle the decoder in desktop builds, and leave JPEG XL validation to Stoat (#1260).
  • Retry temporary Discord server failures within a bounded budget and identify metadata and token-validation requests with the documented User-Agent (#1225, #1271).
  • Correct the exporter's POSIX termination comment without changing how cancellation stops the child process (#1288).
  • Renew feedback request IDs when report content changes, preserve IDs on plain retries, and check the exact serialized body against the 32 KiB service limit (#1239, #1240).
  • Correct self-hosted API URLs, Discord export requirements and token guidance, the first-migration troubleshooting link, and the supported upload body-limit setting (#1283, #1284, #1333, #1334).

[2.43.13] - 2026-10-12

Fixed

  • Upload attachments with their original Discord filenames while preserving readable Unicode names (#1219).
  • Discover custom emoji in forwarded content and embeds, and retain the forwarded message's original timestamp (#1221).
  • Skip numeric boost notifications and legacy empty thread-starter events instead of posting empty-message placeholders (#1220).
  • Exclude thread parent channels from server category creation (#1256).
  • Remove the false animation-loss warning when repair recreates an animated emoji (#1322).
  • Resolve user mentions from the export's mention records, including users who authored no exported messages (#1213).
  • Await the native save dialog when saving a feedback draft, retaining cancellation and browser-only handling (#1236).
  • Use the real DCE 2.48 thread capture in current regression tests and correct unresolved-starter and stdout evidence notes (#1286, #1287, #1290).

[2.43.12] - 2026-10-11

Fixed

  • Name failed Discord exports in run output and report warnings, and show the exporter's error instead of its final stack frame (#1222, #1231).
  • Require all three desktop downloads and the native window module before release, and correct the Intel runner comment (#1279, #1281, #1280).
  • Run the Ubuntu release-asset shell fixtures only on POSIX hosts, without starting Windows' Linux launcher (#1279).
  • Correct the guides' self-hosting limits, rate-limit headers and delay, exporter commands and downloads, bot-token status, and server ID location with source quotations (#1262, #1264, #1267, #1282, #1285).
  • Choose avatar filename extensions from the response image type or parsed URL path (#1292).
  • Declare Python 3.14 support and add it to the CI matrix (#1273).
  • Record GitHub feedback dependency failure classes and HTTP statuses without exception text or credential values (#1297).

[2.43.11] - 2026-10-11

Fixed

  • Preserve Discord channel order inside categories when all ordinary-channel positions are available, retaining the existing order for incomplete or older metadata (#1226).
  • Use numeric Discord IDs to break equal-position category ties while preserving saved migration compatibility (#1226).

[2.43.10] - 2026-10-11

Fixed

  • Read media paths from Windows exports on macOS and Linux while preserving containment checks and refusing absolute Windows paths (#1218).
  • Count Stoat's already-pinned response as success in the pins phase, while retaining other failed pins for retry (#1257).
  • Restore voice channel user caps during repair with the same payload used by migration (#1229).
  • Honor port-qualified proxy bypass entries, with separate cache entries per port and preserved Unicode hostname matching (#1235).
  • Remove the false animation-loss warning for migrated emoji and the corresponding limitation in the guide (#1268).

[2.43.9] - 2026-10-11

Fixed

  • Deliver replies when their mapped Stoat target has been deleted, allowing Stoat to omit the missing reference (#1207).
  • Preserve Unicode attachment filenames in Autumn multipart uploads (#1214).
  • Disable dependency caching in the publishing job, including manual runs from tags (#1176).
  • Report export cancellation as cancellation when the child process has already exited, including on Windows (#1233).
  • Remove the historical plan dependency from two chunk-snapshot tests while retaining instruction and architecture decision checks and their CI skips (#1320).

[2.43.8] - 2026-10-11

Fixed

  • Open all five tool pages before tab storage connects. Saved tokens remain available after connection, and entered tokens take priority (#1194).
  • Move Apple silicon release builds to macOS 15. Inspect minimum-version fields and executable deployment targets in the built and extracted application without changing the promised operating-system minimum (#1191).
  • Apply age restriction when creating a server, preserve reused-server diagnostics, and bound server, role and channel names (#1168, #1182).
  • Send role colours as CSS strings during build, migration and repair, while keeping stored blueprint colours as integers (#1183).
  • Split message content, markers and reactions within Stoat's UTF-8 byte limit. Bound embed descriptions by bytes and URL fields by characters (#1185, #1186).
  • Stop on malformed channel exports or message containers. Report eager and streamed message errors with their source filename and message identifier or ordinal (#1170, #1198).
  • Escape literal percent signs in exporter output paths and download the pinned asset directly, retaining existing checksum, size and archive checks (#1189, #1190).
  • Store desktop output and interface settings in writable per-user directories, preserving saved output choices (#1195).
  • Keep the local interface available after native startup failure and request one browser fallback after readiness. Preserve ordinary window closure, explicit shutdown and browser-only mode (#1196).

[2.43.7] - 2026-10-11

Fixed

  • Retain a safe structured record when a direct Vibe or Qwen review attempt fails, and preserve Qwen's classified timeout phase through advisory and selected-plan collection (#1148).

[2.43.6] - 2026-10-11

Fixed

  • Review multipart Full-route chunks from content-addressed snapshots before shipping creates one commit per atomic task. Shipping now rejects missing, changed, reordered, or unrelated evidence, binds real provider artifacts and posted markers to the reviewed bytes, and keeps final Full reviews and existing provider-failure and finding-verification rules.

[2.43.5] - 2026-10-11

Fixed

  • Keep a best-effort Windows process-tree cleanup from turning a passed packaged-app smoke test into a failed release job. The workflow now reports cleanup failures but clears that native status before the existing application assertions decide the job result.

[2.43.4] - 2026-10-11

Fixed

  • Refuse public plain HTTP and redirects before sending Stoat credentials through shared API requests, token verification, Autumn uploads, deep upload probes, or rate-limit probes. HTTP remains available for localhost names and private or loopback IP literals (#974).

[2.43.3] - 2026-10-11

Fixed

  • Require repository evidence selected for a brainstorm recommendation to be read again when its file changed or became unavailable after its receipt was recorded (#991).

[2.43.2] - 2026-10-11

Fixed

  • The guide for finding your Stoat user token now points to the right place. The current Stoat web app keeps the token in the browser's IndexedDB (localforage, then keyvaluepairs, then the auth row, under session.token), and its Local Storage is empty, so anyone following the old steps found nothing to copy. The setup guide, the hint under the token field in the browser interface, and the --token help text of three commands now name the new location. The guide also warns against copying userId from the same preview by mistake.

[2.43.1] - 2026-10-11

Changed

  • Weekly dependency update (Dependabot #1137). nicegui 3.17.1 → 3.18.0 (the GUI shell) and aiohttp 3.14.3 → 3.14.4. NiceGUI 3.18 made its reconnect handshake asynchronous and added tab ids to it, so the feedback tests' reconnect helper now awaits it and passes the page's own tab id. Ferry's own code does not call it.
  • GitHub Actions updates (Dependabot #1132 to #1136). actions/checkout 6.1.0 → 7.0.1, actions/cache 5.1.0 → 6.1.0, actions/upload-artifact 6.0.0 → 7.0.2, actions/download-artifact 7.0.0 → 8.0.2 and astral-sh/setup-uv 7.6.0 → 10.2.0, still pinned by commit. Each pinned commit was checked against its release tag and declares the Node 24 runtime. Two of these majors change defaults, and neither changes Ferry's builds: setup-uv 10 turns its cache off by default for three event types, but every Ferry workflow turns the cache on explicitly, and download-artifact 8 fails on a checksum mismatch, where it used to warn. Uploads are still zipped by default, so the release job downloads the same files as before.

[2.43.0] - 2026-10-11

Changed

  • Workflows now declare the least token permission each job needs (#968, #969). release.yml used to give every job write access and the right to request a PyPI identity token, including the build jobs that also run for pull requests from a branch in this repository. Those jobs now read only, the tag-only release job keeps the write access it needs, and the Windows signing step runs for tags only. ci.yml, auto-tag.yml and docs.yml also state their permissions, with docs.yml granting the Pages rights to the deploy job alone. A new test fails if a workflow leaves permissions unstated or gives a pull request job write access.
  • New reference page, Release protections, records what the repository settings protect today, what could not be read, and the settings the owner can still change.
  • The known limitations guide now says the local web interface has no login by design, and that the validation page's acknowledgement is a reminder while the migration's own review is the safety step (#958, #1000).

Fixed

  • A Qwen review that keeps streaming no longer holds the review slot for the full ten minutes (#739). One 97,137-character plan review kept streaming until the ten-minute total deadline, while two runs of the same prompt finished in 285 and 303 seconds. The 30-second idle deadline never fired because the model kept sending reasoning events. The review now stops 450 seconds after the response starts, and the record shows failure_class schema with failure_reason stream-runaway in place of a plain timeout. Normal runs are unaffected. The 600-second total deadline stays as the outer bound.
  • The Context7 documentation helper is now installed from a committed lockfile and checked by its hashes (#995). Before, npx downloaded version 4.1.1 on each launch and could not verify it, and the 13 packages it depends on were free to change inside their version ranges. The lock in config/agent-compat/context7/ now records an integrity hash for the helper and for all 104 packages in its tree. The installer runs npm ci --ignore-scripts against it, and npm ci stops if any download does not match. The launcher then starts the installed copy with the same node that runs the launcher, so it no longer uses npx or searches PATH for a program. The install is skipped when the lock has not changed, and a failed install leaves the previous reviewer runtime in use. You need to reinstall the reviewer runtime once to get the helper: run node scripts/agent-compat/codex-bootstrap.mjs. Until then the Context7 launcher stops with a message that says so. The installer names the public npm registry and ignores your npm user settings for this one install. Keeping other npm settings and registries out is tracked in #961.
  • Restore merged thread content when ferry repair recreates a parent channel (#310). With --thread-strategy=merge, a thread's messages are added to its parent channel. Repair used to bring back only the parent's own messages and warn that the thread content was missing. It now re-sends the parent's messages first, then each thread after its separator line, in the same order and format as the migration. The new messages use their own idempotency keys, so Stoat's cache of earlier keys cannot swallow them. A thread with no merge mark in the state is not sent, because --skip-threads and --min-thread-messages leave no other trace, and the warning names it. --dry-run still sends nothing.
  • Check the destination ids saved in state.json before resuming or recovering with them (#970). --resume, ferry retry, ferry rollback, ferry repair, ferry check and ferry stats read server, channel, role, category, emoji and message ids from the saved state and sent requests to them. A value that was not shaped like an id, such as a number where text belongs, crashed the run late or aimed a request at the wrong place. Ferry now checks every saved id when it loads the state and stops with a message that names the field, for example channel_map['123']. Nothing in a rejected state is used. Older states that hold valid ids load as before. A hand-edited or mismatched state is a mistake to fix, and nothing here treats it as an attack. Four recovery steps also check the saved id against what Ferry created. ferry retry skips a failed message recorded against a channel that is not in the channel map, and keeps it with a warning. Rollback deletes only the channels the live server lists, which is also what the confirmation summary shows. The pin and reaction steps drop a queued entry aimed at an unmapped channel. The reaction step also drops a reaction with a custom emoji Ferry did not upload. ferry repair leaves an emoji rewrite record alone when the emoji map does not point at its new emoji. A bad state now shows as a message, not a traceback, in ferry migrate --resume and in the GUI rollback. ferry retry and ferry rollback now refuse a dry-run state, as resume, check and repair already did, and a dry-run placeholder must match the exact shape Ferry writes. The web interface shows why saved progress cannot be read and waits for an explicit Start Fresh, instead of starting over on its own.

[2.42.2] - 2026-10-11

Fixed

  • Keep collected discord_ferry modules connected to their parent packages after NiceGUI cleans up a browser-page test (#1144). Later tests can again patch the collected Autumn module by dotted path.

[2.42.1] - 2026-10-11

Fixed

  • Reject a directory passed to the Autumn uploader before Ferry tries to open it as a file (#1039). Missing paths keep their existing error, and regular-file uploads are unchanged.

[2.42.0] - 2026-10-10

Changed

  • Export validation warnings now carry the channel name in its own channel_name field (#154). The three warnings that concern one channel (http_attachment, rendered_markdown and empty_export) used to hold the name only inside the message text, so anything that needed it had to pick it out of the sentence. The field is also written to state.json and migration_report.json, where it is masked for secrets exactly as the message is, because a channel name comes from the export. Older state.json files without the field still load and resume. The check that keeps unclassified warning fields out of those files now also covers the warnings built by export validation, which it previously missed.

Fixed

  • Download an avatar from an export only when its address is an https Discord image server address (#965). An export could name any address as an author's avatar, such as a service on the local machine or on the private network. Ferry fetched it and uploaded the response to the Stoat media service, where other people could see it. Ferry now fetches only cdn.discordapp.com and media.discordapp.net over https on the standard port, with no username or password in the address. Any other address is skipped with the usual "avatar_download_failed" warning, and the member keeps the default avatar. Redirects were already refused. Exports made with media downloaded use local paths and are unaffected.
  • Give the chat exporter only the environment it needs (#976). Ferry used to start DiscordChatExporter with a full copy of its own environment, so every secret Ferry could see, such as the Stoat token or an unrelated API key, was visible to the exporter. The exporter now receives a short list of variables. These are the path and temp settings, the Windows system folders, and the home and locale settings. They also include the proxy and CA bundle settings (HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, NO_PROXY, SSL_CERT_FILE, SSL_CERT_DIR). The Discord token is set from Ferry's own configuration. Everything else is left out. On Windows the names match without regard to case. The exporter is a self-contained .NET program, so it needs no DOTNET_ROOT. Of the DOTNET_* settings only DOTNET_SYSTEM_GLOBALIZATION_INVARIANT is passed on; if you relied on another one reaching the exporter, tell us in an issue. The real exporter is now also started on Windows in CI, so a missing Windows setting fails the build instead of a user's export.
  • The feedback service now runs one GitHub search for a pending feedback request at a time (#959). A report whose first send was cut off stays pending, and each repeat of that same request searched GitHub for the report on its own. Repeats of a pending request do not count against the report quota, so several at once meant several searches. Repeats that arrive while a search is running now wait for it and get its answer, whether it found the report or not. Once it ends, the next retry searches again. The first search also marks the receipt delivered, so the other repeats no longer fail with a server error when they try to do the same. A request that is already delivered still never reads from GitHub. The feedback service needs a redeploy for this to take effect.
  • The feedback service now remembers a failed GitHub readiness check for 5 seconds (#963). A successful check was already kept for 30 seconds, but a failed one was forgotten at once. Every caller of /ready, and every feedback submission that arrived during an outage, then ran the full chain of GitHub requests again, one after another. Callers now share one failed check. After 5 seconds the next caller checks again, so a recovered GitHub reads as ready within that time. A delivery failure still forces a fresh check on the next request. The feedback service needs a redeploy for this to take effect.
  • ferry repair now removes the extra pinned forum index that migrations before v2.41.30 left behind (#1142). Those migrations posted two index messages per forum, and only the second was recorded, so a later --incremental run kept the second one current and left the first as a stale copy. For each forum with a recorded index message, repair reads the index channel's pinned messages. It deletes any other message that starts with the same **Forum: <name>** heading. For a forum with no posts, older migrations posted the text No posts migrated. with no heading, so a message that reads exactly that is deleted too. Either way the message must have been sent by Ferry's own account and carry the "Discord Ferry" name. It never touches the recorded message, and it removes nothing if the recorded message is not among the pinned messages. A delete that fails is recorded as a warning, which gives repair a non-zero exit code, and repair carries on with the rest. --dry-run lists what it would remove and deletes nothing. The JSON output gains removed_duplicate_indexes under actions. Normal migrations are unchanged.

[2.41.30] - 2026-10-10

Fixed

  • Stage saved documents and the downloaded server banner under a random, exclusively created temporary name (#960). Ferry used a predictable name such as state.json.tmp. In a shared output folder, another account could plant a symlink at that name and send the write to a file it could not otherwise change. The temporary file is now created fresh in the same folder, so an existing entry is never reused or followed. The files Ferry writes (state, reports, blueprints, metadata, banners) are now readable only by the account that ran Ferry. Direct writes that stay in place (avatars, role-icon staging files and thread archives) now refuse to follow a symlink at their path. A planted link makes the avatar or role icon fail with the usual warning, and a thread archive that cannot be written is recorded as a warning while the rest of the phase carries on. The avatars, role-icons and threads folders are checked the same way: if any of them, or a folder inside threads, is a symlink, Ferry refuses to use it. The banner folder gets the same folder check. On Windows these direct writes open the file in binary mode, so an image is written byte for byte.
  • Refuse web requests that do not address Ferry as a local machine (#964). A hostile website could point its own name at 127.0.0.1 (DNS rebinding) and then talk to Ferry's local interface from the visitor's browser, reaching pages that write files such as the blueprint export. Ferry now answers only when the Host header is 127.0.0.1, localhost or [::1], with or without Ferry's own port. Any other Host gets a 400 on pages and a closed connection on the live-update websocket. The native window and the browser address Ferry prints use 127.0.0.1:8765, so they are unaffected.
  • The feedback service now keys the hash it stores with each receipt (#962). The hash was a plain SHA-256 that covered the optional contact email, which is otherwise encrypted. Someone who could read only the database, without the encryption key, could have tested guessed addresses against a report whose other fields were known. The hash is now an HMAC-SHA256 with a subkey derived from the service's contact key, so it cannot be recomputed without that key. The email is still part of what the hash covers, so a request ID replayed with a different address is still refused with a 409. Receipts saved before this change still match on a retry. A pending one still finds its GitHub item without creating a second, and the retry replaces the old hash. Receipts that are never retried drop out after their usual 7 days. The Ferry client is unchanged. The feedback service needs a redeploy for this to take effect.
  • A forum index is no longer posted twice, and a duplicate index send is now remembered (#560). When Ferry created a forum's index message it never recorded the message id, so the rebuild at the end of the run found none, sent a second index and pinned it too. Every forum ended a normal migration with two pinned index messages. Ferry now records the id when the first send succeeds, and the rebuild edits that message. When the first send comes back as a duplicate, Stoat returns no id, so Ferry records the forum as "present, id unknown", the same way the rebuild step already did (#215). The rebuild then looks for the index among the pinned messages and edits it instead of adding a second one.
  • Bind the cached Discord metadata to the export before anything uses it (#971). A leftover discord_metadata.json from another server no longer feeds its role and channel permissions into a migration, on a resume or a fresh run. --resume stops with an error that names the file. A fresh run carries on without the file and warns once. The file is rejected when its server differs from the export or the configured server id, and when a permission value is a boolean, negative or not a whole number. A resume also rejects an id that is not all digits. A corrupt file no longer crashes the pre-creation review. Permission bits Stoat does not define are dropped, with one warning. A valid cache is used as before. ferry repair and the migration report check the file against the export too, and a malformed user-override entry makes the file unusable instead of crashing the review or the report.

[2.41.29] - 2026-10-10

Changed

  • Drop the em dash from the rest of the messages Ferry shows while it migrates (#1139). This covers the emoji skip, animation and failure lines, and the oversized, expired and missing attachment skips. It also covers the reaction cap line, the missing guild icon and role hoist warnings, the probe voice and webhook results, and the phase-complete lines for emoji, pins and reactions. They now use a semicolon, a colon or parentheses. Only the wording changed. Text Ferry posts into the migrated server, the forum index lines and the thread archive headings, keeps its wording so servers migrated over several runs stay consistent. A new test fails if an em dash comes back into any other string in the migrator modules.

Fixed

  • Strip control characters from the Markdown migration report (#1080). Text from the export, such as an author name, an attachment file name or a channel name, could carry a newline, an escape byte or another control character into migration_report.md. The writer only masked secrets, so those characters landed in the warning and error lines. The report writer now turns line breaks and tabs into a space and removes every other control character, so each warning and error stays on one line. Secrets are masked both before and after this step, so neither a control byte inside a secret nor a tab that is part of one can slip past the mask. The JSON reports and state.json are unchanged. The server name in the guild icon warnings is also shown in quotes with escapes and a 120 character cap, the same way the icon path already was. The ferry check output uses the same shared helper as before.
  • On Windows, Ferry now retries a file swap that fails because another program has the target open (#176). Saving state.json, the reports, the blueprint, a saved feedback draft or a downloaded banner could stop with PermissionError (WinError 5 or 32) while OneDrive or an antivirus scan held the old file. Ferry tries up to five times, waiting 0.05, 0.1, 0.2 and 0.4 seconds between tries, then raises the original error. Other platforms and other errors are not retried. A failed document write now also removes its .tmp file, and if that removal fails too, the original error is the one reported.
  • The credential launcher, the Codex and Claude review launchers and the Context7 launcher now find their client program once, as an absolute path, before they do anything else (#977). Before, they passed a bare name such as pass-cli or npx to the operating system, which searched PATH, including empty, relative and . entries and any folder inside the working checkout. A look-alike file planted in one of those places could have been run, and in the credential and Context7 cases it would have received the access token or the key. The new helper resolve-executable.mjs skips those entries, takes the first executable file on an absolute entry, and the launcher runs that path. A missing program now stops with "executable not found" before a token file is read or a key is fetched. Where npx is a node script, the Context7 launcher runs it with the node that started the launcher, so the script's #!/usr/bin/env node line does not search PATH again. The Context7 server it starts gets a PATH holding only the safe entries, and npx starts in the home folder, because npx puts the node_modules/.bin folder of its working folder and every parent ahead of PATH. Its own node lookup therefore cannot reach a program planted in the checkout either. The second-opinion launcher already starts its Python interpreter from a fixed path under the home folder, so it is unchanged. Homebrew, ~/.local/bin and npm global installs work as before. The reviewer runtime gains one file, so reinstall it from main.

[2.41.28] - 2026-10-10

Changed

  • Drop the em dash from the warnings Ferry writes when a media path fails the export-root check (#1081). The eight unsafe_media_path messages (avatar, emoji, server icon, sticker, masquerade avatar, embed media and attachment) and their matching progress lines now end in ; skipped. or read (reason). Only the wording changed. A new test fails if an em dash comes back to that family.
  • Show tokens passed through environment variables in the docs, and point to them in the help text (#978). The guides and CLI reference examples no longer put --token or --discord-token on the command line, where a token is saved in shell history and can show up in process listings. They read STOAT_TOKEN and DISCORD_TOKEN from the environment or a .env file, and --help for migrate, build, rollback and probe now says to prefer those variables. The literal options still work. The environment route keeps the token out of shell history, but not from every other process on the machine.
  • When Ferry runs the export itself, it now passes the Discord token to the exporter (DiscordChatExporter) through the DISCORD_TOKEN environment variable instead of the --token argument (#978). Before, the token showed in a process listing while the exporter ran. Exporter 2.48 reads that variable when the argument is absent, and the contract test against the real exporter now checks that it still names it.

Fixed

  • The feedback service now deletes expired contact details on an hourly timer while it runs (#982). Expiry was already enforced when data was read, but the rows were only removed at startup or during later store work, so an idle service could keep them past the stated 30 days. The timer is cancelled and awaited on shutdown, and one failed sweep is logged without row data and does not stop later sweeps.
  • Refuse a DiscordChatExporter JSON file over 4 GiB and a blueprint file over 10 MiB before reading them, and report JSON nested too deeply as the same clean error instead of a RecursionError (#988). Both loaders used to read the whole file into memory with no limit. The size comes from the file system, so nothing is allocated for a refused file. A refused channel file stops the migration with an error naming the file and the limit, so no channel is left out of a run that reports success.
  • Upload reuse now keys on the media tag as well as the file path (#984). Autumn file ids belong to the tag they were uploaded under (avatars, emojis, icons, banners, attachments). The cache and the in-flight map used the bare file path. One file used under two tags could therefore get back an id from the wrong tag. Saved entries are now stored as <tag>:<path>. A migration resumed from a state.json written by an older version does not match its bare-path entries, because they record no tag, so each such file is uploaded once more on that resume. No other state field changes and old state.json files still load.

[2.41.27] - 2026-10-10

Changed

  • Pin every remote GitHub Actions reference in .github/workflows/ to the exact commit it names (its full SHA, a fixed ID), with a trailing # vX.Y.Z comment, and pin the build backend to hatchling==1.32.4 (#1003). The selectors were moving (@v6, @release/v1), so a retag upstream could change what CI and the release job ran. Majors are unchanged. Dependabot now also watches the github-actions ecosystem and proposes SHA bumps weekly. The backend pin is bumped by hand.
  • Document in scripts/agent-compat/verify-all.mjs that it is a trusted-checkout local command (#1001). It runs in and trusts the current checkout, passes the caller's environment to its children, and picks helpers by content, not provenance. Point it at an unreviewed checkout, or add it to automation, only behind a reviewed ref or owner approval. No behaviour changed.
  • The Qwen and Codex session-start hooks now present the last five commit subjects as untrusted repository metadata (#997). The subjects sit inside a labelled <untrusted-commit-subjects> block that tells the model to read them as data and never as instructions. Each subject is capped at 100 characters, flattened to one line, and has &, < and > escaped, so no subject can close or forge the block. Both hosts share scripts/agent-compat/session-context.mjs.
  • The local web interface no longer interprets X-Forwarded-For and X-Forwarded-Proto headers (#998). uvicorn trusts them from 127.0.0.1 by default, and Ferry has no proxy in front of it and never reads the derived client address or scheme. Browser and native-window use are unchanged.

Fixed

  • The Vibe reviewer now calls Mistral directly and rejects a reply that does not name zai-glm-5-2, so the review record shows the model that actually answered (#990). It used to run the vibe command-line program, whose output leaves out the model name, so the record stored the requested model as the resolved one without proof. The record's session id is now the completion id from the reply. The reviewer no longer needs the Vibe client installed, so the Codex and Qwen reviewer readiness checks no longer look for it.

[2.41.26] - 2026-10-10

Changed

  • Ignore common credential file names in .gitignore: credentials.json, *.pem and *.key (#979). None are tracked today, so this only stops an accidental git add from picking one up.
  • The Context7 documentation helper now starts @upstash/context7-mcp@4.1.1 (published 2026-09-14) instead of whichever version is newest on each launch (#995). Only the version is pinned: npx cannot check an integrity string, and the package's own dependencies still float inside their declared ranges.
  • The destructive git check shared by the Qwen, Codex and Vibe hooks now recognises more spellings of the commands it already targeted (#996). It splits a line into commands only at separators and brackets outside quotes, skips shell comments, and reads quoted and backslash-escaped words, including $'...' and $"..." quoting and a backslash before a newline, unambiguous long-option abbreviations such as --forc, a + on a push refspec, branch -fd and -d -f, and git checkout .. It no longer flags git restore -S ., which only touches the index, and it reads a value glued to restore -s as that value, so it cannot pass for -S. It sees through the wrappers env (including env -S), command, exec, timeout and sudo with options, and looks inside bash -c (also with --), sh -c, eval, $(...) and backticks (also inside double quotes), subshells and then blocks. Table-driven tests cover each spelling and each host's deny response. Not covered: commands built at run time (variables, aliases, find -exec), a git command run by another program such as python -c or ssh, checkout -f, and the Claude Code hook, which is a separate untracked substring check (#1129).

Fixed

  • The DiscordChatExporter download now stops reading at the 150 MB size limit. It used to buffer the whole response and only then compare its size, so an oversized or endless response was held in memory in full. A declared Content-Length over the limit is now refused before any of the body is read (#1127).
  • ferry rollback, retry, repair, check and backfill-roles now mask the registered Stoat token in the errors and progress lines they print (#1128). Before, they escaped Rich markup and nothing else, so a server error that echoed the token reached the screen in full. ferry build got the same fix in #972.

Security

  • Update locked dependencies with open security advisories: GitPython 3.1.62, PyJWT 2.15.0, urllib3 2.8.0 and multidict 6.9.1. Also update python-dotenv 1.2.4, ruff 0.16.10 and mypy 2.4.0. Supersedes Dependabot pull requests #951, #952, #953, #1058 and #1066.
  • Update the feedback container's pinned uv to 0.12.18 for GHSA-2cv4-cqwr-gwf7 (Windows only, so the Linux container was not exposed). The pin keeps hashes for the same three Linux wheels as before. Supersedes #1056 and #1057, which listed every platform's wheel.

[2.41.25] - 2026-10-10

Fixed

  • Remote avatar downloads now stop at the Autumn avatar size limit of 4,000,000 bytes (#1108). Before, the whole response was read into memory before any size check, so a very large or endless body could use all available memory. A declared length over the limit is refused before any body is read, an undeclared body is cut off as soon as it passes the limit, and nothing is written to disk on refusal. Avatar downloads also no longer follow redirects, matching the banner and role icon downloads.
  • Log and report redaction now masks the longest registered secret first (#972). Before, it masked in registration order, so a short secret such as a proxy password that was a prefix or suffix of a longer one (a Stoat token) was masked first, the longer value no longer matched, and the rest of it stayed in the output. Mask shapes are unchanged.
  • ferry build now registers the Stoat token for redaction before its first request (#972). Before, the token had no redaction coverage in the log file during that command. Its progress lines and its final error on screen now mask the token as well.

Changed

  • Banner downloads no longer send the Discord token, since Discord's image server does not need it (#975).
  • Agent compatibility now requires the validated plain-English 1.7.0 release (#1124). The local installer and generated-state check accept plain-english@1.7.0, whose Qwen chat hooks use a 60000 ms timeout instead of 10000 ms and which no longer writes the Vibe judge launcher. The installer removes a leftover .vibe/hooks/plain-english-judge.mjs so the staged check passes.
  • Require five exporter tests to check the exact low-disk, partial-channel, proxy-resolution, and stale-export warning outcomes (#1079). Changes to an event's status, phase, or message no longer satisfy the affected coverage.
  • Require nine engine and message tests to check their exact warning outcomes (#1079). The assertions cover validation, missing tokens and pin references, skipped forwarded content, invalid reaction modes, oversized attachments, and forum indexes whose message IDs are unknown. They also verify that dry runs skip validation and oversized attachments make no upload request.
  • Require the remote-image emoji test to check the exact recorded and emitted missing-media warnings and verify that no upload or emoji creation is attempted (#1079).
  • Require the post-migration validation failure test to check the exact API timeout warning while preserving the migration-completion check (#1079).
  • Require the thread-truncation test to check the five surviving threads and the exact recorded and emitted channel-limit warnings (#1079).
  • Require the avatar HTTP 404 test to check the exact avatar_download_failed warning and failure reason while preserving the no-upload check (#1079).
  • Require the animated-emoji test to check the animated_emoji warning, the specific animation-loss event, and successful emoji creation (#1079).
  • Require the role-ordering failure test to check the specific recorded and emitted warning, and the final role-phase progress event (#1079).
  • Require the emoji-limit test to check the exact surviving emoji, upload and creation counts, and the recorded and emitted limit warnings (#1079).
  • Run the application test suite on Windows with Python 3.12, replacing the four-file Windows atomic-write job (#174). The three Linux Python versions and the separate exporter contract job remain in place.
  • Accept the Windows console attribute's type-check suppression on both Windows and other platforms, so Windows type checking can reach the test step.
  • Keep developer-tool suites and the Linux container runtime test on Linux. Correct exporter executable and feedback path fixtures for Windows; check Unix file-mode bits only on Unix while retaining the feedback save checks on Windows.
  • Require avatar download tests to assert the avatar_download_failed type and the relevant failure reason (#1079). An unrelated warning no longer satisfies the non-image and timeout coverage.
  • Require channel and emoji limit tests to assert their named warning types and truncation details (#1079). Warnings from the same migration phase no longer satisfy this coverage.
  • Require the voice-channel fallback test to assert the exact channels warning event (#1079). An unrelated warning no longer satisfies the fallback coverage.

Added

  • Add a repeatable reconstruction of the source-derived export fixture from reviewed dummy values (#1002). The offline tool checks the pinned writer digest and disposition ledger, rebuilds identical fixture bytes, and documents the source review required when updating it.

[2.41.24] - 2026-10-09

Fixed

  • The review verifier now runs commands that outside reviewers write with quotes (#1078). Before, any quote character made it refuse the command, so every such finding became INCONCLUSIVE and nothing the reviewer found was checked. A quoted search such as rg -n 'a|b' README.md now works, and quoted paths still go through the containment check.
  • An rg or git grep search with no -- now gets one inserted before its pattern when every earlier word is an allowed flag. A pattern that starts with a dash, or an unknown flag in front of the pattern, is still refused.
  • The reply format check and the design-evidence hook now split a command with the same shared splitter as the verifier. It matches /bin/sh and refuses anything a shell would expand, chain or redirect. An unquoted wildcard such as rg -n -- foo.* README.md is now refused, so quote the pattern. A command the reply check cannot read now makes only its own finding INCONCLUSIVE and no longer invalidates the whole reply.
  • An authorized verification command now also carries command_line, the full command with every word single-quoted. A quoted word can hold shell characters such as $(id) or a|b, so a host that runs the command through a shell must use this line and never join the words itself. A test runs a pattern of $(touch MARK) through /bin/sh and checks that no file is created.
  • The reviewer runtime gains one file, shell-words.mjs. Reinstall the runtime from main so the installed checker picks up these changes.

[2.41.23] - 2026-10-09

Fixed

  • Role icon and server banner downloads from the Discord CDN now stop reading as soon as they pass their size limit (2.5 MB and 6 MB), refuse an oversize Content-Length before reading the body, and no longer follow redirects (#980, #981). A banner is written to a temporary file and moved into place only after a complete, in-limit download, so a failed or oversize download leaves the previous banner file untouched.

[2.41.22] - 2026-10-09

Fixed

  • The exporter runner no longer keeps every DiscordChatExporter error line in memory during a run (#987). Only the last line was ever reported, so a noisy run grew without need: 200,000 error lines peaked near 28 MB. It now keeps just the last line, and the error message is unchanged.

[2.41.21] - 2026-10-09

Fixed

  • Ferry no longer installs a DiscordChatExporter download it cannot verify (#973). If the bundled checksum file (dce_checksums.json) is missing, the download now stops with an error that suggests reinstalling Ferry or passing --skip-dce-verify, where before it skipped the check and reported the exporter as ready. The --skip-dce-verify bypass works as before.

[2.41.20] - 2026-10-09

Fixed

  • Fixture provisioning now uses the selected manifest marker in text and forum channel topics (#985). Custom-marker channels can be recognized by fixture verification and teardown. An offline regression test checks the created topics and both consumers.

[2.41.19] - 2026-10-09

Fixed

  • Qwen now runs the native plain-English chat writing-style check on main-session and subagent completion (#942). The installer adds the hooks and launcher while preserving Ferry's existing document checks and guards. Drift verification checks the merged settings and launcher.

[2.41.18] - 2026-10-08

Fixed

  • The guild icon path is resolved against the export folder (#1041). It was resolved against the process working directory instead, so an export-relative iconUrl, which is what every DCE export writes, did not exist and the icon was skipped. Server icons have therefore never been migrated from a genuine export. Servers migrated before this release keep no icon; set one in Stoat server settings.
  • A crafted export can no longer read a guild icon from outside the export folder (#1041). The icon path is now checked against the export root like the seven other media read sites, so an absolute or parent-traversal iconUrl is refused with an unsafe_media_path warning instead of being uploaded. A named icon that is not present now warns icon_file_missing instead of being skipped with no output at all, which is what kept the resolution bug invisible for as long as it did. A remote http or https spelling is skipped with no warning, because it was never a local file and reporting it missing would name a path that is not in the export.
  • Both existing guild icon tests asserted too little to notice a rejection. One checked only that some event message contained the substring icon and the other only that some event had status warning, so any icon warning satisfied both. They now name the outcome: the success event and the absence of icon warnings, and for the failure case the icon_upload_failed type. Nine further cases cover the escapes, a missing file, a directory, a remote spelling, a hostile spelling, and the working-directory regression.

Added

  • Server icons now appear on the destination server. This is the user-visible half of the resolution fix above, and it is listed here as well because it is behaviour no previous release produced. It restores what the icon upload code was always meant to do rather than adding a new capability, which is why this release is a patch bump.
  • A third security probe. tests/security_probes/probe_1041.py drives the real run_server under sandbox-exec and reports whether bytes from outside the export root reached the upload boundary. Its runner follows the same argument and exit-code contract as run-955.sh, so exit 1 means regression and the scratch directory is a required argument. It reuses sandbox-955.sb rather than adding a third copy of the profile.

[2.41.17] - 2026-10-08

Fixed

  • The inflated-overwrite warning no longer claims a send that failed. The channel_override_admin_inflated warning said Ferry "sent it with no allowed permissions", which was false when that send failed. It now says Ferry removed the allowed permissions and kept the denied ones, which is true either way. A failed send still records its own channel_default_perm_failed or channel_role_perm_failed warning.
  • The Vibe reviewer no longer sees the owner's personal skills. Vibe reads user-level skills from the .agents folder in the home directory, and that lookup ignores VIBE_HOME. The reviewer now also points HOME at its temporary directory, so it starts with no skills from the machine it runs on.

[2.41.15] - 2026-10-08

Fixed

  • A Discord channel overwrite holding the administrator permission no longer grants everything on that channel in Stoat (#986). Discord checks that permission only on server roles, so inside a channel overwrite it grants nothing. Ferry turned it into every Stoat permission, including managing the channel and its permissions, and for an @everyone overwrite that meant every member. Fresh fetches now drop the bit inside overwrites and translate the rest.
  • A saved Discord metadata file from an earlier version can no longer reapply that grant. When a resumed run or ferry repair sends a saved overwrite that holds a permission only the administrator expansion produces, Ferry sends it with no allowed permissions, keeps its denied ones, and records a channel_override_admin_inflated warning naming the channel. Servers already migrated keep whatever they were given: neither ferry check nor ferry repair looks for this on a live server.

[2.41.14] - 2026-10-08

Fixed

  • The Vibe reviewer no longer discards reviews that start with prose. The zai-glm-5-2 model often writes a paragraph before its fenced JSON answer. The Vibe adapter stripped a fence only at the very start of the reply, so it rejected those complete reviews with "Vibe review returned invalid JSON". In repeated runs on one chunk payload, one reply in four had this shape. When a reply starts with prose, the Vibe and Claude adapters now read the last fenced block that parses as JSON. Each fence pairs with its own closer, so a quoted code block ahead of the answer no longer swallows it, and Windows line endings are accepted. A fence quoted inside a JSON string never matches, because a fence boundary needs a real line break. Replies that start with JSON or with a fence are read exactly as before. The findings check still runs on whatever is read, and a reply with no JSON at all is still rejected.
  • The Vibe reviewer no longer fails with "Turn limit of 1 reached". The ensemble records this failure at stage vibe-child. Vibe trusts its working directory and loads that directory's .vibe hooks, tools, skills and plugins, and the reviewer ran inside the Ferry checkout. The checkout's plain-English post-agent hook read the review's JSON answer as a chat reply and injected a rewrite request. That spent the single allowed turn, so Vibe exited with an error and the review was lost. Every Vibe child now starts in an empty temporary directory. From inside the checkout, two of three runs failed this way before the change and none of three after it.

[2.41.12] - 2026-10-08

Fixed

  • The brainstorm evidence guard no longer switches itself off. Two rules blocked real brainstorms in the same checkout.
  • A turn that ended with no matching prompt record used to suspend an active brainstorm. That happened after a hook ended a turn and after the owner ran a ! command. A suspended record blocks nothing, so the check stayed off until /df-brainstorm restarted it. The restart then discarded every receipt already collected. Such a turn now passes unchecked, and the brainstorm stays active with its evidence. A missing tool event can only withhold a receipt, never create one, so this cannot let incomplete evidence through.
  • Receipts left by an earlier brainstorm made the validator reject the whole current record with "Brainstorm evidence belongs to another generation". That blocked every later recommendation until the files were moved by hand. They are now skipped. They still cannot count as evidence.
  • A record suspended by an earlier version still restarts through /df-brainstorm, and a test now covers that path. ADR-031 records both changes.

[2.41.11] - 2026-10-08

Fixed

  • The brainstorm evidence guard reads Claude Code shell exit codes. The guard read tool_response.exit_code on Claude, and Claude Code's Bash result has no such field. Its result carries stdout, stderr, interrupted, isImage and backgroundTaskId, and the hook event that follows a successful call (PostToolUse) fires only for a command that exited
  • A non-zero exit fires the failure event (PostToolUseFailure) instead, with no tool_response and a top-level error string whose first line is Exit code N. A challenge command run on Claude therefore never completed its receipt, so a design recommendation could never pass the guard there. Found on 2026-10-08, when a passing comparison run left no receipt. The guard now records exit 0 for a finished successful call, and the code from the first line of a failure. It records no outcome for an interrupted or backgrounded command, a timeout, a user interrupt, or a shell that never started, because a default code would let a challenge expecting that code pass without the command finishing. The Claude tests now send the payload shapes from Claude Code's hook and SDK documentation. They previously sent an exit_code field Claude never sends, which is why the defect passed. Codex and Qwen behaviour is unchanged.

[2.41.10] - 2026-10-07

Fixed

  • The brainstorm evidence guard reads Qwen shell exit codes (#1036, #1037). Qwen sends no integer exit code. Its shell text ends with an Exit Code: N status line, which arrives in tool_response.llmContent in the hook event that follows a successful tool call (PostToolUse) and in the top-level error string in the event that follows a failed one (PostToolUseFailure). A failure event has no tool_response at all. The guard read neither place. A failing challenge command on Qwen therefore never completed its receipt, which left the recommendation blocked, and every successful event recorded exit 0. That included exit 1 from grep, rg, diff and test, which Qwen counts as success. The guard now reads the last status line from the right field. A result with no numeric status line records no outcome, because a default code would let a challenge expecting that code pass without the command finishing. Examples are a timeout, a signal kill and a background start. A user interrupt also records no outcome. The Qwen tests now send the payload shapes found in the qwen-code 0.25.0 source and run as the Qwen host from start to finish (#1040). They previously used fields Qwen never sends, which is why the defect passed. Claude and Codex behaviour is unchanged.

[2.41.9] - 2026-10-07

Fixed

  • Repair now reports failure when it cannot restore a recreated channel's delay between messages (slowmode) or voice participant limit (#1062). The command line exits 1 and the repair page reports outstanding problems. Previously both reported success despite the warning. This applies ADR-036's existing rule for attempted operations that failed.

[2.41.8] - 2026-10-07

Fixed

  • Repair no longer exits 0 on a server default it could not converge (#1060). The two convergence warnings that leave work outstanding, server_default_manual and server_default_check_failed, now gate the repair exit code, so the CLI and the GUI repair page both report a server that still needs an owner edit in Stoat server settings, or whose stored default repair could not read at all. Previously both printed a warning and exited 0, which told a script the server was whole. server_default_reset stays out of the gate: it records a convergence repair completed, and gating on it would fail the successful path. The dividing line, a failed operation gates and a re-run-curable degradation does not, is recorded in ADR-036. That record backfills a contract the registry had been extended against three times already (#307, #311, #344) with no written rule. One cost is explicit: a transient failure while reading the stored default now exits 1 where 2.41.7 exited 0, and a re-run clears it. That is intended, since the alternative is a green light on a run that never read the server.

[2.41.7] - 2026-10-06

Fixed

  • Repair converges servers that still carry the migration floor (chunk 2 of #957). Every non-dry repair now fetches the stored server default and, when it equals exactly the translated mask OR the migration floor, rewrites it to the mask alone with a server_default_reset warning. Owner-edited defaults and metadata-less output directories warn server_default_manual without writing, because zeroing the field would drop members below Stoat's stock default. Converged and Administrator-mask servers take no action. Fetch, coercion and write failures warn server_default_check_failed and never abort the repair.

[2.41.6] - 2026-10-06

Fixed

  • The migration floor no longer lands on the shared server default (chunk 1 of #957). The server-phase bootstrap write is deleted and the roles phase restores the translated Discord @everyone mask alone. Every member of a migrated server previously inherited ManageRole, ManageCustomisation, ManageMessages, Masquerade and six more bits intended for the migration account; the floor is now an account-side grant only. Repair convergence for servers migrated before this release lands in 2.41.7.

[2.41.5] - 2026-10-04

Fixed

  • Repair and emoji routes enforce containment (chunk 5 of #955/#956). The repair emoji join uses the shared containment helper, so an escaped image_url skips recreation with the existing emoji_missing_media warning instead of uploading the outside file. The emoji route swaps its inline resolve-and-check block for the helper, which additionally rejects absolute-inside-root paths and survives symlink loops that previously crashed the phase. tests/test_emoji.py is unchanged and green as the refactor guard.

[2.41.4] - 2026-10-04

Fixed

  • Avatar phase and downloader enforce containment (chunk 4 of #955/#956). Local avatar paths that escape the export root are skipped with an unsafe_media_path warning and counted as failed, and the remote avatar downloader rejects author ids that are not safe filename components before any request, plus a resolved-destination check against the output root so a symlinked avatars directory cannot redirect writes. Cached authors short-circuit before both checks, so resume behaviour is unchanged.

[2.41.3] - 2026-10-04

Fixed

  • Message-phase consumers warn on escaped media instead of uploading it (chunk 3 of #955/#956). Attachment, sticker, embed, and masquerade routes now skip export-supplied paths that fail the containment check, emit an unsafe_media_path warning through the correct per-channel or state accumulator, and count skipped attachments. _build_masquerade returns an escape flag so both send routes (per-message and merged-thread) record the warning. Escaped skips never enter failed_messages, so resume does not retry them. Note for resumed runs: the upload cache key moved to resolved path spellings in chunks 2 and 3, so a migration interrupted before this release re-uploads its cached files once (#1038).

[2.41.2] - 2026-10-04

Fixed

  • Sticker and embed media honour the export-root containment (chunk 2 of #955/#956). flatten_embed and handle_stickers drop local candidates that escape the export root and report the escapes to their callers; all 46 unpack sites moved to the three-value returns. Escaped sticker and embed media can no longer reach the upload boundary. The per-consumer warnings arrive with chunk 3.

[2.41.1] - 2026-10-04

Changed

  • Weekly dependency group update (Dependabot #947). Runtime-facing: nicegui 3.16.0 → 3.17.1 (the GUI shell), multidict 6.7.1 → 6.9.0 and yarl 1.24.5 → 1.25.1 (both under aiohttp). Toolchain and build: ruff 0.16.5 → 0.16.8 and pyinstaller 6.22.2 → 6.22.3 (with pyinstaller-hooks-contrib 2026.6 → 2026.7). Ruff 0.16.8 now reports SIM117 on six nested with blocks in the proxy and message tests, so each inner block joins its outer one. The context managers enter in the same order, so the tests check the same thing.

Fixed

  • The linked-hosts installer test runs again (#948). Its fixture copied .agents with the skill links turned into real directories, so the skill topology check stopped the installer before the write-through guard was ever exercised. The fixture now keeps the links as links, gives the fake primary checkout its own skills, and leaves one link missing so the installer has work it must not write through. Removing the installer's linked-host skip now fails the test. CI skips this test, so the failure only showed locally.

  • Export-supplied local media paths now validate against the export root (chunk 1 of #955/#956). The new discord_ferry.parser.media_paths module resolves the root and the candidate, rejects absolute strings and symlink escapes, including symlink loops where pathlib raises RuntimeError, and returns the contained path or None. No consumer adopts it yet in this chunk; chunks 2 to 5 switch the seven join sites.

  • The brainstorm evidence guard reads Qwen hook payloads. Qwen's PostToolUse carries the tool result under llmContent and returnDisplay while the guard read only snake_case keys, so source receipts on this host hashed empty text and no recommendation could quote a line. The two keys append after the existing six, leaving Claude and Codex behaviour unchanged, with a regression test that feeds a Qwen-shaped payload.

[2.41.0] - 2026-09-04

Added

  • Vibe readiness checker and setup entrypoint. vibe-readiness.mjs runs 12 static checks mirroring the Codex readiness structure, and vibe-setup.sh orchestrates install and readiness. The installer now mentions both paths.

  • Qwen readiness checker and setup entrypoint. qwen-readiness.mjs runs 12 static checks mirroring the Vibe readiness structure, and qwen-setup.sh orchestrates install and readiness. The installer now mentions both the Vibe and Qwen paths.

  • Qwen Code now gets its own instruction file. QWEN.md carries the project rules for the Qwen host, the way CLAUDE.md does for Claude. It is gitignored, linked into new-worktree.sh worktrees, copied by .worktreeinclude, named in AGENTS.md, and the worktree contract check now requires the link.

  • Brainstorm evidence now runs on the Qwen host. .qwen/settings.json registers the evidence guard on all five hook events, the guard derives its own turn identity because Qwen payloads carry none, and challenge results read the shell exit code from Qwen's rendered result display. The parity ledger records the five routes as ported, and ADR-031 names the host.

  • Second-opinion Mistral access on Qwen no longer waits on the launch environment. second-opinion-mcp.mjs reads the Mistral key from the existing item-limited Proton grant at server startup and injects it into the second-opinion server, and the Qwen template registers the launcher so it overrides the shared .mcp.json entry on this host. ADR-035 records the change and amends the ADR-026 key step.

  • Vibe config now includes a bash allowlist. The Vibe config template auto-approves the same commands Qwen's permission list covers.

  • Vibe support gap investigation and fixes. Fixed 15+ Claude-only tool name references in skills and rules, replaced CLAUDE.md atomicity rule references with AGENTS.md, reworded Claude hook references as host-specific, documented the fresh-context reviewer limitation, and added cross-references for operational guidance.

Fixed

  • Qwen serena context was set to "vibe" instead of "qwen". Copy-paste from the Vibe template. Serena accepts the qwen context, satisfying ADR-026 re-evaluation trigger 2.

  • Readiness worktree checks now pass the contract paths. The Vibe and Qwen readiness checkers called check.mjs --check-worktree-contract with a --root flag that this mode rejects, so the check failed on every real checkout. Both now pass the five contract paths the way Codex readiness does.

  • Qwen sessions can now dispatch the reviewer runtime. The Qwen permission list allows the reviewer-runtime ensemble and verification commands that /df-ship and /df-chunk-review run from the shared runtime directory, so required reviews do not stop at a permission prompt on this host.

  • Workflow routing now follows verified risk instead of file count. Narrow reversible fixes use one approved bounded path even when release support touches several files. Unknown causes are investigated first, high-risk contracts keep the complete design and review chain, and optional agent or reviewer ideas wait for owner-selected triage.

  • Design critique now stops after three attempts. The final attempt checks unresolved claims against current sources, then returns the decision to the repository owner. Failed, timed-out, and interrupted attempts count toward the same limit on every supported coding tool.

  • Brainstorm recommendations now require independent evidence on Claude, Codex, and Qwen Code. The workflow records real source reads and predeclared challenge results before it permits a selected approach. Prepared, suspended, cancelled, and unrelated work remain outside the recommendation gate.

  • Account-free feedback now starts inside Ferry. The app and the new ferry feedback command collect Bug, Idea, or General reports without a GitHub account. Both routes show an exact public preview before sending. Bugs become labelled GitHub Issues, while ideas and general feedback become Discussions in their matching categories.

  • Feedback privacy and recovery stay under the user's control. Diagnostics and private contact are optional and reviewed separately. The public route accepts no raw exports or attachments, cleans diagnostic credentials again at the service boundary, and retains encrypted contact for no more than 30 days. A failed send keeps its request identity and offers only user-chosen retry, copy, save, edit, or cancel actions.

  • The isolated feedback service is ready for the Personal Apps environment in Coolify. Its dedicated GitHub App routes Issues and Discussions, while proof challenges, rolling quotas, durable receipts, and pending-write reconciliation protect the public boundary. The operator guide covers private storage, health, readiness, contact deletion, receipt recovery, key rotation, rollback, log audits, deployment, and production smoke cleanup.

  • Discord Ferry now links to Peter Sterkenburg's GitHub Sponsors profile. The final README section gives readers a direct path to support Peter's open-source work.

  • Managed exports now cover every DCE 2.48 command-line target. Ferry selects and verifies all nine official Windows, Linux, and macOS archives from Python compatibility tags. Every pinned archive is self-contained, so exports no longer require a separate Microsoft runtime.

  • Current DCE message metadata reaches migration. Message and embed inline emoji metadata can supply local assets without inflating usage counts. Application commands add the command name and invoking user's display name while retaining the original message author.

  • The DCE 2.48 JSON contract has executable evidence. A source-pinned ledger gives every writer path an exact consumed or ignored disposition. Reviewed fixture-guild captures prove eager and streaming replay while credential, local-path, provenance, and continuous-integration guards keep the evidence bounded.

  • Codex setup is repeat-safe and self-checking. One command now installs scoped trust, reviewer access, command rules, linked-worktree state and static readiness checks. Paid live checks remain explicit. An expired reviewer credential is renewed on the next setup run.

  • Code review uses fixed Vibe and Qwen providers. Gates dispatch zai-glm-5-2 and qwen3.8-max, validate their result schema, and verify accepted findings. Provider outages are advisory, while confirmed Critical findings block.

  • Plan review stops after two calls. A per-plan ledger counts attempts before dispatch and keeps the first selected provider fixed. Qwen remains the default, while Opus 5 requires an explicit owner choice. Provider failure is advisory. A second confirmed blocker requires the owner to accept the recorded risk or return to design.

  • Qwen Code becomes the fourth parity host. The agent-compat generator now renders .qwen/settings.json from config/agent-compat/qwen-settings.json: the Ferry guard hooks registered directly (Qwen speaks the Claude hook envelope), the qmd, serena and context7 MCP servers, the model tiers, and a permissions allowlist. Prompt hooks are merged from .claude/ at install time, so plain-english stays the single source for its judges. Worktree and ignore coverage now carries AGENTS.md, .agents/ and .qwen/. ADR-026 records the decisions. Closes #590.

  • Plan and design review goes cross-host. Both review helpers gain --mode design, and /df-critique gains a step 4b that dispatches the design document through the same fallback chain the code reviews use, on every host. On the Qwen host the chain is Codex, Claude, then the Mistral MCP. Qwen is its own model family, so no leg of the chain is circular.

Changed

  • First weekly dependency group update (Dependabot #588). The grouped uv updates enabled in 2.34.0 ran for the first time. Dependabot moved thirteen packages, three transitive companions moved with them, and two packages left the lockfile; none of the thirteen carries an open security advisory. Runtime-facing: nicegui 3.12.0 → 3.16.0 (the GUI shell), click 8.3.3 → 8.4.2 (the CLI), ijson 3.5.0 → 3.5.1 (incremental parsing of the chat export), certifi 2026.4.22 → 2026.7.22, python-dotenv 1.2.2 → 1.2.3, and yarl 1.23.0 → 1.24.5. Toolchain: ruff 0.15.13 → 0.16.4, mypy 2.1.0 → 2.3.1 (with librt 0.11.0 → 0.15.0 and ast-serialize 0.4.0 → 0.8.0), pytest 9.0.3 → 9.1.1, pytest-asyncio 1.3.0 → 1.4.0, and aioresponses 0.7.8 → 0.7.9. Build and docs: pyinstaller 6.20.0 → 6.22.2 (with pyinstaller-hooks-contrib 2026.5 → 2026.6) and mkdocs-material 9.7.6 → 9.7.7. nicegui 3.16.0 dropped its lxml dependency, so lxml 6.1.0 and lxml-html-clean 0.4.4 left the lockfile. Suite is green on the new stack: 2,338 passed.

  • Ruff 0.16.4 formats Python code blocks inside markdown. The pinned 0.15.13 accepted the embedded snippets in docs/reference/architecture.md and docs/reference/stoat-api-notes.md as they stood; the new ruff applies its usual rules to those blocks, and the CI format check enforces the result. Both files were reflowed to match. Whitespace-only; no prose and no snippet semantics changed.

Fixed

  • Feedback container tests remove their temporary images. Each Docker-backed run deletes its exact unique image tag after container and volume cleanup. Setup failures after a successful build still clean the image without hiding the original error.

  • The final migration report now contains results from its own post-migration check. Ferry finishes the optional check before writing either report format or announcing completion, so users cannot open the report before current check results have been stored.

  • Required Python checks finish faster without dropping coverage. Test-only API state isolation and bounded retry-wait repairs let each existing Python job run its full owned suite across four workers. Python 3.11, 3.12, and 3.13, both CI triggers, required status names, and every pre-existing test remain.

  • Coolify can verify the feedback container before routing traffic. The runtime image now includes the HTTP client used by Coolify's /health deployment check.

  • GitHub App keys issued in RSA PEM format now start the feedback service. Startup accepts the standard RSA footer returned by GitHub while retaining the existing closed validation for the private key field.

  • The feedback container smoke test now uses deployment-style private storage. Its Docker volume preserves the image's non-root ownership for /data, so the test checks the service instead of failing on a CI runner-owned bind directory. Startup failures now include the container log.

  • The native plain-English process cleanup check now allows bounded operating-system teardown. It still fails if a descendant survives for two seconds, but no longer treats the few milliseconds between process-group signalling and PID removal as a leaked hook process.

  • Qwen schema failures preserve a safe rejection reason. Failed advisory and plan records now distinguish stream framing, response JSON, and findings validation without retaining provider content. Existing failure fields and valid review records keep their prior contract.

  • Codex completion hooks no longer depend on the current directory. Main-agent and child-agent checks now use their generated Ferry paths, so finishing work outside the checkout does not produce a generic hook process failure.

  • Qwen review findings now survive rejected verification commands. Provider records validate response structure, while the local guard blocks unsafe commands as inconclusive findings. The live readiness check now proves one non-empty authorized Qwen finding.

  • DCE archives cannot write outside their cache directory. Ferry rejects absolute, parent, and cross-platform escape paths before extracting any archive member. The checksum bypass remains available without bypassing path containment.

  • DCE emoji assets cannot read outside the export directory. Ferry rejects absolute, parent, and symbolic-link escape paths before uploading local reaction or inline emoji media.

  • Closed-poll notifications and active-poll limits now match DCE 2.48. Closed notifications retain DCE's ordinary fallback text and embeds. The unsupported active-poll model and formatter are gone because DCE does not export active poll state.

  • Codex chat hooks now have a 60-second outer budget. The plain-English judge can finish its normal check, while generated-state checks still detect hook drift.

  • Generated Qwen settings contain no hosted key. Model entries name an environment variable, and reviewer credentials come from Proton Pass at call time. Generated host files reject inline credential shapes.

  • Review verification stays within the checkout. Read-only finding commands now resolve real file targets and reject links that point outside the project.

  • The drift verifier no longer demands the legacy .vibe/mcp.toml. The installer renders Vibe's MCP servers into .vibe/config.toml and removes the old file, but check.mjs kept asserting the old path, so agent-check.sh failed on every checkout. The check now matches .vibe/config.toml.

[2.40.6] - 2026-09-03

Fixed

  • Required review collection no longer asks for approval on every payload. Entering a Ferry plan, chunk, or shipping review gate authorizes its fixed Vibe and Qwen calls. Codex starts those calls through the external route, while optional destinations and local finding work stay outside that authorization.

  • Vibe and Qwen reviewer credentials now use verified item-only access. Setup replaces the former empty-vault grant with exact Viewer grants for the two required key items. It checks both fields on creation, repeat runs, and renewal, gives protected login enough bounded time for the live account, and can narrow the exact former installation or recover a safely bound interrupted creation.

[2.40.5] - 2026-09-03

Fixed

  • Every live Claude path in the df workflow now uses Sonnet 5. Claude sessions, implementation guidance, external reviews, plan-review records, and shipping attribution agree on the same tier. Qwen remains the default plan reviewer, and review effort, safety controls, attempt budgets, provider locking, advisory failures, and verdict rules retain their behavior.

[2.40.4] - 2026-09-02

Fixed

  • Codex GitHub commands now work inside the bounded workspace sandbox. Generated setup enables command networking through an exact api.github.com and github.com destination policy. Readiness rejects missing or widened policy, and unrelated destinations remain denied.

[2.40.3] - 2026-09-02

Fixed

  • Context7 now reads its item-only Proton grant by identifier. Setup saves the item share and item identifiers returned by Proton. The protected launcher uses them because an item-only credential cannot find its grant through a vault-name search.

[2.40.2] - 2026-09-02

Fixed

  • Context7 setup now accepts Proton Pass CLI's actual agent response. Setup reads the token from agent creation, then records the matching pat_id returned by the agent list. This fixes first-run setup without exposing the token or granting broader access. A failed list call after creation can be recovered on the next setup run.

[2.40.1] - 2026-09-02

Fixed

  • All four coding hosts now use the same protected Context7 key from Proton Pass. Setup gives Context7 read access to only its existing item. The generated settings contain no copy of the key, and logs never print it. Startup stops with a safe message when the key cannot be read.

[2.36.0] - 2026-08-29

Added

  • Check report JSON identifies contract version 1. The standalone check document, saved validation results, and repair's completed post-check now carry one integer from the shared serializer. A versioned oracle covers fields, nullable types, nested counts, statuses, and every emitted kind, so contract drift requires an explicit increment. Closes #303.

[2.35.7] - 2026-08-28

Fixed

  • Linked worktrees keep branch-owned ship manifests separate. Build instructions write the manifest under each checkout's ignored plans directory. Focused checks and Codex readiness reject writer or reader instructions that return to shared state. Closes #678.

[2.35.6] - 2026-08-27

Fixed

  • Agent compatibility now accepts the validated plain-English 1.0.0 release. Ferry keeps one exact generated-host prerequisite and retains the native Codex and Vibe artifact, timeout, cleanup, and process-tree checks introduced for 0.24.1. Closes #692.

[2.35.5] - 2026-08-27

Fixed

  • Agent compatibility now requires and verifies plain-English 0.24.1. Ferry checks the exact host version before generated files change, uses the native offline Codex and Vibe launchers, preserves the two 60-second Codex stop routes, and compares fresh staged artifacts. Closes #668.

[2.35.4] - 2026-08-27

Fixed

  • Review findings now cross a decidable local verification boundary. Provider records require authorized direct commands and mutually exclusive structured outcomes. Finding data moves through bounded project files, while command checks and verdicts stay in the workspace sandbox. Git checks disable repository-configured external tools. Closes #665.

[2.35.3] - 2026-08-26

Fixed

  • Slot-safety tests close their outgoing-message queues. Two tests created full web-interface clients without starting the application loop, leaving queue jobs for a later test to collect. A local queue substitute keeps the real client-context assertions without starting that job.

[2.35.2] - 2026-08-26

Fixed

  • Implementation-plan reviews can no longer repeat without a limit. One plan gets at most two external calls across revisions. Failed and interrupted calls count, a provider switch is rejected, and a third attempt stops before contacting a model. Approval after a second confirmed blocker is tied to the exact plan and findings. Ledger paths reject linked directories outside the checkout, and overlapping calls complete the round they claimed. Closes #659.

[2.35.1] - 2026-08-26

Fixed

  • Qwen plan reviews now finish through a bounded streamed request or report a specific failure. The reviewer requests strict structured output, allows the measured reasoning time, and distinguishes connection, idle, and total timeouts from credentials, provider responses, rate limits, request errors, wrong models, and invalid schemas. Qwen remains the default plan reviewer. An owner can select the separate medium-effort Opus route, but a provider failure never starts another paid model. Closes #650.

[2.35.0] - 2026-08-23

Added

  • Forum index messages whose id was lost to a DuplicateSendError are now recovered by searching the channel's pinned messages. The index is pinned after send, so a pinned-message search finds it, edits it with current counts, and records the id. This un-freezes an index that previously stayed stuck with stale counts because there was no id to edit. Closes #561.

[2.34.0] - 2026-08-23

Added

  • report.json and the markdown report now include merge_duplicates_suppressed, a per-run counter of thread messages the merge strategy skipped because the parent channel already held them. The suppression stays silent (no per-message warning, no change to the "Merged thread X (N messages)" event), but the counter makes it observable. Closes #239.

Changed

  • The six inspect.getsource source-string assertions in tests/test_gui.py are replaced by NiceGUI user-fixture tests that execute real page code. The export, setup, migrate, and validate screens now have user-fixture coverage that catches regressions by running the actual page builders, not by matching source text. Closes #147.

  • Dependabot scheduled version updates are now on. A .github/dependabot.yml enables weekly grouped updates for the uv ecosystem. Security updates continue as before. ADR-025 records the decision to enable this consequential default. Closes #158.

[2.33.0] - 2026-08-23

Added

  • ferry check now detects renamed emoji. The name Ferry creates for each emoji is recorded in state.json and compared against the server on check, producing an emoji_renamed warning when they differ. Closes #304.

  • Cross-host second-opinion review. The /df-ship and /df-chunk-review review gates now run a cross-host fallback chain so the reviewer is never the same model family as the host running the build. On Vibe, Codex reviews with Claude as fallback. On Claude Code, Codex reviews with the Mistral MCP as fallback. On Codex, Claude reviews with the Mistral MCP as last resort. A new helper, scripts/agent-compat/claude-review.mjs, wraps claude -p with --json-schema and no tool access (--allowedTools ''), returning findings in the same code_review_findings schema as the Codex helper. Closes the circular-review gap noted in ADR-023.

  • Ferry paces Stoat API requests proactively before a rate-limit bucket exhausts. Stoat sends X-RateLimit-Remaining on every response, not just 429s. Ferry now reads that header on every response and pauses before the next request to the same bucket when the remaining budget reaches zero, waiting for the advertised window reset instead of burning a request to discover the bucket is empty. A shadow counter decremented before each request prevents two concurrent requests from both firing into the same exhausted budget. This applies to every Stoat API call through the shared request layer, so the structure, emoji, reactions and pins phases, which previously had no damping at all, now benefit alongside the messages phase. The existing adaptive multiplier and 429 retry path remain unchanged as a safety net. Closes #111.

Fixed

  • Pacer no longer over-spends rate-limit budget on 5xx and network-error retries. The proactive pacer decremented its shadow counter before each request attempt, but only the 429 path refreshed the shadow from response headers. A request that retried on a 502 or a network error burned one unit per retry against a stale shadow, causing the next request to pace spuriously. The 5xx and network-error paths now refund the decrement, so one logical request consumes exactly one unit regardless of retry count.

  • content: Reference pages now describe the rendered-mention check accurately. Two reference pages still framed the VALIDATE check as detecting only a missing --markdown false flag. The check also flags @Unknown, which DiscordChatExporter writes for a member it could not resolve, and no longer fires on a reply, an embed-carried mention or an attachment-only message. Closes #153.

  • content: Em-dash and banned-word debt removed from known-limitations guide. Five em-dashes and a silently in docs/guides/known-limitations.md failed the plain-english lint. Replaced with commas, parentheses, and a split sentence. The lint is now clean.

  • The agent-compat installer no longer wipes writing-style lint hooks on refresh. agent-install.sh wrote ferry-only hook templates as a full overwrite, stripping the plain-english lint hooks from Codex and Vibe. It now writes the ferry blocks, then runs plain-english init to merge the lint blocks back in. The drift verifier checks ferry blocks are present and plain-english is in sync, instead of exact-matching against ferry-only templates (ADR-024).

  • The writing-guide skill is now visible to Codex and Vibe. The writing-a-document skill was added after the last install and was not bridged into .agents/skills/. Running the installer bridges it; the skill count is corrected to 15.
  • The shared contract no longer names a Vibe model that is not the one running. The cognitive tiering table now says "active model in ~/.vibe/config.toml" and the tool mapping lists the explore read-only subagent Vibe exposes.
  • The session pre-flight skill is now in the on-demand list. /df-start was bridged and available but undocumented in AGENTS.md and CLAUDE.md.
  • The fallback-reviewer MCP table no longer implies it is live on Codex. It now says the MCP is disabled on Codex and the codex exec CLI is the primary reviewer.
  • The Linear issue-tracker channel is stripped from all hook configs. The plain-english issue channel matched the Linear MCP, which is not used in this repo. The installer now strips it after merging, and the parity ledger no longer carries the entries.
  • CI now runs an agent-config template check. The gates job runs agent-check.sh --ci, which validates tracked templates and parity structure without reading gitignored files, so template drift fails the build instead of going unnoticed.

[2.31.0] - 2026-08-22

Added

  • ferry repair recreates and rebuilds a missing forum index. When a forum's index channel is deleted from a migrated server, repair now recreates it, puts it back at the top of its forum category, and rebuilds its pinned index message from Ferry's own record of the forum's posts, instead of declining. A forum index whose channel survives but whose message alone was deleted is rebuilt in place. If the forum's own category is also gone and cannot be restored this run, repair declines that index with a clear warning and a non-zero exit. Closes #311.

Fixed

  • A forum-index rebuild no longer posts a duplicate index message on a later run. When Stoat accepted the index message as a duplicate, Ferry recorded nothing, so the next run sent it again once Stoat's idempotency cache had evicted the key. Ferry now records that the index is present with an unknown id and skips the resend, leaving the existing message in place rather than adding a second copy. Closes #215.

[2.30.0] - 2026-08-21

Added

  • ferry repair restores a recreated role's colour, hoist and icon. When repair recreates a role that was deleted from a migrated server, it now sets the role's colour, hoist setting and icon (the icon is re-fetched from Discord and re-uploaded) the same way the original migration did, provided the Discord metadata is present. Rank is still restored only by a full --incremental re-run, and a role with no Discord metadata is still declined with a warning. Closes #344.

[2.29.0] - 2026-08-21

Added

  • The finished-migration screen links straight to Verify and Repair. When a migration completes, the completion card now offers Verify and Repair buttons that open the matching tool page for the migration that just ran, carrying its output directory across so the page is ready to run (#518).
  • The token-taking tool pages accept a Stoat token when the session store is empty. Check, repair, retry, probe and build now show a password token field instead of a dead-end "Session expired" message when no session token is present. A migration clears the session token on completion, so a tool page reached from the finish screen would otherwise have no way to run; the field lets the user supply a token for that one run without it being written to disk (#524).

[2.27.0] - 2026-08-21

Added

  • GUI parity with the CLI toolchain, phase 3: the read-only diagnostics reach the web interface. The Tools screen at /tools gains its last three pages, none of which needs a token:
  • Validate: check a Discord export before migrating and list any warnings, including the reason a plain-text-mention warning has to be acknowledged.
  • Stats: summarise a finished migration from its saved state, with the same entity counts, fidelity scores, and error and warning summary the command line shows.
  • TLS check: show the certificate-trust and proxy state the app resolved, with no input needed.

[2.26.0] - 2026-08-21

Added

  • ferry repair restores a missing emoji. When ferry check reports an emoji that was migrated but is now gone from the Stoat server, repair recreates it from the export and rewrites every message Ferry sent that referenced the old copy, so those references point at the restored emoji instead of a dead id. Recreating an emoji mints a new id, so the rewrite is the half that makes the restoration real.
  • A crash partway through is finished on the next run. The recreation and a small resume record are written in one step, and a later run completes any messages that were not yet rewritten, so an interrupted repair never leaves a half-updated server behind.
  • An emoji whose image is not in the export is reported as unrepairable rather than guessed at. A reference in a later send of a message split across the 2000-character limit is left as is, because only the first send can be addressed.
  • A dry run previews the work without changing anything, and the result appears in both the plain output and ferry repair --json.

[2.25.0] - 2026-08-21

Added

  • GUI parity with the CLI toolchain, phase 2: the preflight and build commands reach the web interface. The Tools screen at /tools gains three more pages:
  • Probe: check a Stoat instance before migrating, showing Autumn upload limits, the rate-limit window, voice-channel support, and whether webhooks are enabled, as a per-check pass, warn, or fail table. A deeper probe that uploads a test file at each size boundary is available behind a confirmation, because it leaves those files in Autumn storage.
  • Blueprint export: turn a Discord export directory into a reusable server blueprint file, and confirm before replacing an existing file.
  • Build: create a Stoat server from a preset template or a blueprint file. The page states up front that a server built this way cannot be rolled back, because it records no migration state.

Changed

  • The Discord-to-blueprint channel mapping and the server-build sequence each now live in one place, shared by the command line and the web interface, so the two cannot drift apart on them. Behaviour is unchanged.

[2.24.0] - 2026-08-20

Added

  • GUI parity with the CLI toolchain, phase 1: the post-migration commands reach the web interface. Until now the web interface could only run a migration; verifying, repairing, and retrying a migration were command-line only. A new Tools screen at /tools adds:
  • Check: verify a finished migration and see a per-entity pass or fail table, with the same verdict ferry check gives.
  • Repair: recreate missing channels, roles, and categories and resend messages, with a dry-run default on and the same pass or fail rule as ferry repair. A migration that was rolled back is refused, and shown as a neutral refusal rather than a failure.
  • Retry: resend the messages that failed on a prior migration, reporting how many succeeded and how many are still failed.
  • Incremental: a checkbox on the setup page for an incremental re-run, which the engine will not accept together with resume.

Each tool page registers the Stoat token for redaction before it can log, sanitizes every line and table cell it renders (the on-screen log does not pass through the logging redactor), and starts the rate limiter only when it is off, so a read-only tool cannot disturb a migration running in another browser tab. The preflight probe, blueprint export, server build, and read-only diagnostic pages follow in a later phase.

[2.23.0] - 2026-08-20

Added

  • ALL_PROXY is now honoured as a fallback for HTTP and HTTPS. DiscordChatExporter, which Ferry runs as a subprocess, reads ALL_PROXY; Ferry did not, so a user whose only proxy setting was ALL_PROXY got a successful export followed by every later phase connecting direct and failing. Ferry now uses the ALL_PROXY proxy for http and https when no scheme-specific HTTP_PROXY/HTTPS_PROXY is set, so a single ALL_PROXY=http://host:port reaches every phase. A scheme-specific proxy still wins, an explicitly-emptied scheme (HTTPS_PROXY="") is not rescued by the fallback, NO_PROXY and FERRY_DISABLE_PROXY still apply, and the proxy credential is stripped from logs and the connection URL as before. SOCKS proxies remain unsupported, including a SOCKS value in ALL_PROXY, which is reported in the startup notice and in ferry tls-check. Closes #141.

[2.22.0] - 2026-08-20

Added

  • ferry backfill-roles corrects role ordering on a server migrated before the #380 ordering fix (v2.19.2). Migrations run before that fix left their roles at Stoat's default order, and the only correction was a full re-run. The new command reads the same roles the migration built (the export unioned with discord_metadata.json) and sets the server's role hierarchy to Discord order in one call. It creates nothing, touches no role colour or icon, and is safe to re-run: a server already in order gets no write, and roles added by hand after the migration keep their place. Exits 0 when applied or already correct, 1 when ordering was refused, 2 when the state or export cannot be read. The roles collection was extracted from run_roles into a shared helper so the command orders by exactly what the migration built. Closes #388.

[2.21.0] - 2026-08-20

Added

  • ferry repair --json prints a machine-readable outcome document. Alongside repair's human output and its exit code, --json emits one JSON document to stdout describing the run: the channels, roles and categories it recreated (with per-channel re-sent message counts), the tails it restored, the dead-letter drain, everything it declined, the residual failed messages, and a post-repair ferry check of the live server. Under the flag all human output goes to stderr, so stdout stays parseable, and the document is printed through click.echo (never the Rich console, which wraps at 80 columns and can split a string value). The exit-code contract is unchanged. A new RepairOutcome dataclass carries the result out of run_repair; every free-text field is stripped of control characters, and residual failures carry only identifiers, never message content. Closes #308.

[2.20.0] - 2026-08-20

Added

  • ferry probe --deep now runs a boundary-upload probe against the Autumn file server. For each of the six upload tags (attachments, avatars, backgrounds, icons, banners, emojis), it uploads a test file at the advertised size limit and one byte over, then reports whether the server enforces the limit. Probe entities are torn down after each tag. Avatars and backgrounds leave orphaned files because Autumn has no delete endpoint for them; the help text documents this. Fixes #142.

[2.19.17] - 2026-08-20

Fixed

  • ferry build now applies blueprint and template role ordering. The build path sent rank through api_edit_role, where Stoat's handler discards it. Role hierarchy was lost on every built server. The build path now collects all role ids during creation and calls api_edit_role_ranks once with the full ordered list. A failure in the ordering call prints a warning and continues rather than aborting the build. Fixes #387.

[2.19.16] - 2026-08-19

Fixed

  • Hard-kill mid-creation no longer duplicates roles or channels on resume. run_roles previously saved state every 10th role, and run_channels had no mid-loop saves at all. A SIGKILL, OOM or power loss lost the in-memory ID maps, so resume recreated everything from scratch as duplicates. Both functions now persist state.json after every structural create call, matching the existing server-create pattern. During structural phases message_map is empty, so each write is cheap. Fixes #389.

[2.19.15] - 2026-08-19

Changed

  • --incremental help text now describes channel reuse and the re-export use case. The old text said "only migrate new messages since last completed run", which did not surface the main reason users reach for it: continuing a migration after a partial or failed DCE export. Addresses #440.

Fixed

  • GUI now shows the completion card after a cancelled migration. When a user cancelled a migration mid-run, the Pause and Cancel buttons stayed on screen indefinitely and the completion card (which carries the Rollback button) never appeared. The GUI now detects that cancel_event was set after run_migration returns and transitions to a "Migration Cancelled" view with the Rollback button enabled and Open Report disabled (no report exists for a cancelled run). Fixes #385.

[2.19.14] - 2026-08-19

Fixed

  • Dry-run mode no longer crashes in the avatars phase. The avatars phase was the only mutating phase missing a config.dry_run early-return guard. During a dry run it attempted to upload avatars to the placeholder Autumn URL (dry-run.invalid), producing a connection error. The phase now populates state.avatar_cache with synthetic IDs and returns early, matching the pattern used by every other phase. Fixes #438.

  • Avatar download failure warnings now include the specific failure reason (e.g. "HTTP 404" or "non-image Content-Type 'text/html'") instead of the generic "non-image content type or HTTP error" message.

[2.19.13] - 2026-08-19

Fixed

  • Custom emoji in text-mode reactions now render as actual emoji instead of plain names. When reaction_mode is text, reaction summaries appended to messages used the Discord emoji name (e.g. pepe) for custom emoji. They now use the Stoat emoji syntax (:stoat_id:) for migrated emoji, which Stoat renders as the image, or a [:name:] fallback for emoji that were not migrated. Unicode emoji are unchanged. The same fix applies to the [Original counts: ...] annotation in native mode. Fixes #439.

[2.19.12] - 2026-08-19

Fixed

  • Thread archive no longer crashes on thread names with filesystem-illegal characters. Discord thread names are free-form and can contain characters that are illegal in filenames on Windows (< > : " \ | ? *) or that create subdirectories on POSIX (/). Both the parent channel name and thread name are now sanitized before constructing the archive path. Win32 reserved device names (CON, NUL, etc.) are also handled. Two threads whose names sanitize to the same string now produce distinct files instead of silently overwriting each other. Fixes #173.

[2.19.11] - 2026-08-19

Fixed

  • Channel topics are capped at 1024 characters before reaching the Stoat API. Discord and Stoat both enforce a 1024-character limit on channel descriptions, so a valid export never exceeds it. The cap is defense-in-depth for corrupt or hand-edited exports, applied at both the migration and repair create paths. Fixes #343.

[2.19.10] - 2026-08-19

Fixed

  • ferry check summary assertion no longer fails at narrow terminal widths. Rich wraps text inside its panel at the panel's own width, not the terminal's, so the substring match on the summary phrase broke when a newline landed mid-phrase. The test now normalises whitespace before the check. Fixes #437.

[2.19.9] - 2026-08-18

Fixed

  • Proxy password redaction no longer leaves the last four characters visible. SecureTokenStore.masked rendered any 5+ character secret as ****{last4}, a shape designed for opaque 40-character API tokens. For a short, human-chosen proxy password that tail exposed a large fraction of the value, and the base64 proxy_authorization header derived from it carried the same leak. SecureTokenStore now carries a per-key full-mask policy: register and register_secret take a keyword-only fully_mask flag, and masked returns bare **** for those keys. core/http.py's _strip_userinfo registers both proxy_password and proxy_authorization as fully masked. Stoat and Discord tokens keep the existing tail shape. Fixes #149.

[2.19.8] - 2026-08-18

Fixed

  • Credentials embedded in --stoat-url as HTTP basic-auth userinfo no longer reach API error messages unredacted. A stoat_url like https://user:pass@host was interpolated verbatim into MigrationError and StoatConnectionError text at eight sites. The ferry build path registers no secret, so nothing downstream could scrub it. A new redact_url helper in core/http.py strips user:password@ before the URL reaches any error message. Fixes #276.
  • The nicegui dependency floor no longer sits two majors below a symbol gui.py imports at module load. pyproject.toml declared nicegui>=2.0 but gui.py imports ClientConnectionTimeout, which first exists in nicegui 3.0.0. uv.lock pins 3.12.0 and CI installs from the lock, so the floor was never exercised; an environment resolving nicegui 2.x failed with ImportError on import. The floor is now nicegui>=3.0 with an inline comment naming the symbol. Fixes #382.
  • The --version test no longer fails when a NiceGUI page-test module is collected before it. test_version_is_read_from_module_attribute_not_package_metadata accessed the discord_ferry.cli package attribute for importlib.reload, but a preceding NiceGUI page-test re-imports discord_ferry as a fresh package whose cli attribute is never re-set. The test now holds the submodule via importlib.import_module and reloads that reference, which is order-independent. Fixes #156.

Added

  • A packaging test that walks each declared dependency floor against the symbol the code imports. test_dependency_floors_cover_the_symbols_the_code_imports parses the floored dependencies in pyproject.toml and asserts each floor is at least the version its symbol landed in, catching the next floor/import mismatch the lock was hiding. Companion to #382.

[2.19.7] - 2026-08-18

Fixed

  • A send failure on a later part of a split message no longer discards the delivered first part's Stoat id. The outer try/except wrapped both the per-part send loop and the commit block, so a failure on part N>1 skipped the commit entirely, losing the id, autumn references and message counter. The exception handler now runs inside the loop (flag-and-continue), and the commit block runs after it regardless. Fixes #381.
  • GUI resume flag no longer persists across runs when no previous state exists. The storage["resume"] key was written only by the Resume button and never cleared, so a later run with no state.json raised StateError through the generic handler with no useful message. The else branch now resets the flag, and a dedicated except StateError handler clears it and names the cause. Fixes #383.

Added

  • Cross-platform agent support for Codex (OpenAI CLI) and Vibe (Mistral CLI). A generator-driven architecture renders config templates from config/agent-compat/ into gitignored .codex/ and .vibe/ directories. Hook adapters route Codex/Vibe lifecycle events to the same ~/.claude/hooks/ guard scripts Claude Code uses. A parity ledger maps all 24 hooks. A skill topology bridger symlinks all 14 df-* skills. A drift verifier checks generated state. Run ./scripts/agent-install.sh to generate, ./scripts/agent-check.sh to verify.

[2.19.6] - 2026-08-18

Fixed

  • A failed role colour is now retried on resume. Colour was applied inside the create loop, gated on pre_existing_role_ids, so a role already in role_map was skipped on resume. Moved to the attributes pass alongside hoist and icon, gated on roles_finalized, so colour retries the same way those do. Fixes #408.

[2.19.5] - 2026-08-18

Fixed

  • Empty or whitespace-only embed titles no longer cause 400 FailedValidation. Stoat enforces a 1-character minimum on embed titles. flatten_embed now strips these before they reach the API. Fixes part of #349.

  • Embed titles longer than 100 characters no longer cause 400 FailedValidation. Stoat caps embed titles at 100 characters. flatten_embed now truncates to 97 + "..." when the title exceeds the limit. Fixes part of #349.

  • Embed descriptions longer than 2000 characters no longer cause 400 FailedValidation. Stoat caps embed descriptions at 2000 characters. flatten_embed now truncates to 1994 + " [...]". Fixes part of #349.

  • Attachment-tagged uploads no longer reuse cached Autumn file ids. Autumn file ids are single-use: find_and_use_attachment filters by used_for.id not existing, so reusing a cached id for a second message returns 404 NotFound. upload_with_cache now accepts skip_cache and all three attachment call sites in messages.py pass it. Fixes part of #349.

  • Embed descriptions that would exceed Stoat's validate_sum ceiling are now budgeted. The server sums content.len() plus all embed description lengths against a 2000-character ceiling. Step 7b now computes a per-message budget, drops excess embeds with a user-visible note, and tracks the count in embeds_dropped. Closes #349.

[2.19.4] - 2026-08-18

Fixed

  • --validate-after now runs the same verification as ferry check instead of comparing channel and role counts. The old Phase 12 cardinality check could not detect a swapped name, a missing emoji, or a truncated message tail, and it swallowed all exceptions into a warning indistinguishable from "not requested". The engine now calls run_check, which verifies every entity by ID, checks names, and samples message tails. The validation_results dict in state.json changes shape from {passed, channels_expected, ...} to {results, counts, has_failures}. Dry runs are skipped explicitly. Fixes #268.

Changed

  • CheckReport.to_dict() is the single serializer for check results. Both ferry check --json and --validate-after use it. The duplicate _check_report_as_dict in cli.py is removed.

  • scrub_document now masks the detail field in validation results. The old cardinality shape held no free text. The new shape's results[].detail can embed a stringified exception carrying proxy credentials via aiohttp.ClientHttpProxyError. Both validation_results in state.json and validation in report.json are covered.

[2.19.3] - 2026-08-18

Fixed

  • ferry probe rendered server-controlled strings through Rich unescaped, so a stray closing tag in a Stoat response killed all output. A [/bold] or [/red] anywhere in the instance's feature limits, version string, rate-limit headers or an exception message raised MarkupError and gave exit 1 with no table at all. Now wraps c.name and c.detail in escape(), matching _render_check_report which already did this. --json was never affected. Fixes #384.

[2.19.2] - 2026-08-18

Fixed

  • A bool or NaN retry_after in a 429 body defeated backoff instead of falling back to the default delay. float(True) gave 0.001 s and float(False) gave 0.0 s, because bool is a subclass of int. NaN survived the max/min clamp and resolved to 0.0 s. Both paths produced near-instant retries with no real backoff. The guard now rejects booleans and non-finite values explicitly, matching the pattern already proven in uploader/autumn.py. The same fix was applied to discord/client.py, where the audit found an identical unguarded parser. Fixes #386.

Added

  • Counters and flags on the migration documents are now asserted to start from zero. Every int field on MigrationState, RollbackProgress, FailedMessage and ChannelResult must be 0 on a fresh instance, and every bool must be False. Test-only, nothing under src/ changed, so there is no version bump.

This came from the first mutation sweep. Changing a counter's default from 0 to 1 left the entire state test suite passing: a counter that starts at one is not a crash, it is a silently wrong number in every total the migration reports, and no single test was ever going to be written for each of the twenty-eight fields individually. One assertion covers them all and extends to fields added later. Measured: it kills thirty-eight mutants the suite previously missed.

  • Three repository gates, so four project rules are enforced by a script instead of by memory. No user-facing behaviour changes and nothing under src/ was touched, so there is no version bump.

  • scripts/assert-doc-refs.sh checks that paths cited in the project's documentation resolve on disk, and that every architecture decision record has a row in its index and vice versa. It runs locally rather than in CI, because its inputs are not committed to this repository and a CI runner therefore never receives them.

  • scripts/check-deferrals.sh sweeps a pull request for language that defers work without filing it. It runs in a new single-run gates CI job on every pull request, and reads the pull request body as well as the diff and the commit messages. That matters here: this repository rebase-merges most pull requests, which keeps the body out of git entirely, so a sweep reading only git finds nothing. Measured across recent history: 0 matches in 60 commits, 5 in 110 merged pull request bodies.
  • scripts/mutate.sh runs a mutation sweep over the three modules where a test that cannot fail is most expensive: redaction, checkpoint state, and atomic writes. It self-tests first, with one mutation the suite must catch and one it cannot see, and refuses to report if either answer is wrong. Behind a mutation extra, so it is not installed in CI.

  • scripts/check-deferral-fields.sh is now part of the repository. It was previously untracked, which meant the deferral sweep could not reach it from CI.

Fixed

  • Role hierarchy is applied for the first time (#380). Every migration since v1.x sent each role's Discord position as rank on PATCH /servers/{id}/roles/{role_id}, and the Stoat backend discarded it: the upstream roles_edit handler destructures DataEditRole with a rest pattern that does not bind rank. The request returned 200, hoist and icon in the same call still landed, and no warning fired, so every run reported ordering as applied while the server kept its default ranks. Three shipped documents claimed the behaviour worked, and the third, docs/reference/architecture.md, was found only by the whole-branch review after the first two had been corrected. All three are fixed here.

Ordering now goes through PATCH /servers/{id}/roles/ranks, which takes the complete ordered role list and assigns each role a rank equal to its index, making index 0 the top of the hierarchy. Discord's position runs the other way, so the sort is descending, negating the position rather than reversing so the id tie-break stays ascending.

The list is built from a read-back of the server rather than from role_map. That map can lag the server, because the create loop persists state every ten roles (#389), and the route rejects any list that omits a role. Only roles Ferry created are moved: every other role keeps its exact index, so migrating into an existing server with --server-id leaves that server's own hierarchy alone. A refusal for lack of permission becomes a role_ordering_not_permitted warning naming the cause, and any other failure a role_ordering_failed warning. Neither fails the phase.

Three tests that had asserted rank appeared in a mocked request body were replaced. They passed for years against a field the server threw away, because an assertion about what Ferry sent can never observe what the server kept.

[2.19.1] - 2026-08-17

Fixed

  • Pinned messages were never restored because the pin API call used the wrong HTTP method. api_pin_message sent PUT, but the Stoat backend registers the route as POST. Rocket returned a raw HTML 404 for every pin attempt. Changed to POST. Fixes #348.

[2.19.0] - 2026-08-14

Fixed

  • A window that cannot draw Ferry's interface now says so and opens your browser (#128, the last live piece of #123). Previously the window stayed blank, or showed NiceGUI's own "Your browser does not support ES modules", and there was no flag, variable or setting that could get past it. The migration server was running and reachable at localhost:8765 the whole time, but nothing told the user that.

The page now carries its own message and reports itself. Ferry's interface is built from ES modules; an engine that cannot run them still renders plain HTML and still runs ordinary JavaScript, so the page shows an explanation and tells Ferry to open the system browser. A working window cancels both before either is visible, so there is no timer and no waiting period that could fire on a machine that was merely slow.

A link would not have been enough: measured against a real window, clicking one navigates the dead window rather than reaching the system browser.

Added

  • FERRY_NO_NATIVE skips the native window and uses your browser from the start. It is an environment variable rather than a command-line flag because a double-clicked frozen executable has no arguments to read. It also disables the folder picker, which needs the window.

Documentation

  • Four reference claims corrected against source (stoat-api-notes.md, architecture.md, dce-format.md, migrator/probe.py). The last item on the #107 tracker, content only.
  • Stoat does send rate limit headers. Two pages said it sends none. X-RateLimit-Limit, -Remaining, -Reset-After and -Bucket are set unconditionally in crates/core/ratelimits/src/rocket.rs and exposed through CORS. The false claim came from revolt.js's binding documentation, which does not surface them, and the pages now say so: a client binding's docs are not evidence about server behaviour.
  • The bucket table was wrong about keying, which is the part that decides whether concurrency helps. channels (15 per 10s) and messaging (10 per 10s) are keyed by CHANNEL ID, so work spread across channels does not share a budget; only servers (5 per 10s) is shared. The table had no row for channels at all.
  • DCE writes PascalCase enum names, GuildTextChat and GuildNews, not the SCREAMING_SNAKE constants the Discord API documentation uses. The type table listed the latter, and three of its rows had no DCE spelling that matches anything. Three channel types it omitted are added.
  • A comment called Stoat "this fork". Stoat is Revolt renamed, and that hedge is what let a correct permission mapping get dropped once already.

[2.18.0] - 2026-08-13

Added

  • ferry repair, which acts on what ferry check found (core/engine.py, cli.py). Check could name three kinds of damage and do nothing about them. Repair runs the check itself, then recreates a missing channel, role or category under the name Ferry originally gave it, applies its recorded permission overrides, puts it back in the category it belonged to, restores its slowmode and voice user limit, re-sends the messages it held in their original order, restores a channel's lost last message, and drains the dead-letter queue. A recreated role comes back with its name and permissions; its colour, rank, hoist setting and icon are not restored and repair says so (#344). --dry-run reports the plan and changes nothing.
  • ferry retry, exposing a coroutine that had no user surface (cli.py). run_retry_failed has been complete and tested since v1.x with no command able to reach it, so a user with failed messages in state.json had no way to re-send them. Cheaper and narrower than repair: it contacts the server only to send, where repair spends a request per channel checking first.

Fixed

  • run_retry_failed never populated the token store (core/engine.py). _ensure_token_store was called by run_migration and run_rollback and by nothing else, so config.token_store stayed None for the whole retry path. safe_sanitize is an identity function with a None store, so a Stoat token appearing in an exception reached state.failed_messages, and through it report.json, unredacted. Latent only because no command could reach the coroutine, which ferry retry changes.
  • A repair's sends could be swallowed by Stoat's idempotency cache (migrator/messages.py). Every send key is built from a Discord identifier, which a recreated channel does not change, and Stoat's key store is a 1000-entry in-memory cache with no expiry. A repair running while those keys were still held would be answered "duplicate" and treat a message the server had lost as already delivered, restoring nothing while reporting success. _process_message takes an idempotency_salt, empty for a migration so its keys are unchanged, and repair passes the new channel identifier.

Changed

  • Permission application is callable from two places (migrator/structure.py). The channel and per-role override blocks are now apply_channel_permissions and apply_role_permissions, so a repair applies exactly what a migration applies rather than recomputing a mask. The server-default call that follows the role loop is deliberately not part of it: it writes onto the server's default role, which every member holds. Both take the phase label as a parameter, so a repair-time failure is recorded as one.

Documentation

  • cli-reference.md gains both commands, their options and their exit codes.
  • known-limitations.md gains what repair cannot reach: it inherits every limit of the check, and a mid-channel gap, a message accepted as a duplicate, merged thread content, a forum index and a custom emoji are each declined for a stated reason.

[2.17.0] - 2026-08-12

Added

  • ferry check now detects a renamed channel or role. Ferry records the name it gives each channel and role as it creates them, so Check can compare that against the current name on the server and report a rename as a warning rather than passing it silently. A rename does not fail the command, because nothing has been lost. This works for migrations run under 2.17.0 or later: an earlier migration recorded no such names, and nothing can recover them after the fact, so a rename there stays invisible (#269).
  • ferry check --json prints the whole report as a single JSON document instead of a table, for a script that wants the individual results rather than a pass or fail. Every free-text value has control characters stripped, because a consumer that parses the document and prints a value would otherwise inherit whatever the server sent (#266).

Changed

  • ferry check now names why a result could not be verified, instead of listing every possible cause. Ferry records which thread strategy a migration ran under, so a merge parent is reported as expected rather than as one of three possibilities. A migration run by an earlier version recorded no strategy, and Check still lists the possibilities for those (#267).

Fixed

  • The recorded thread strategy is the one that actually ran. A configuration carrying a value outside flatten, merge and archive runs as flatten, and the state file now says so rather than repeating what was asked for. The CLI rejects such a value outright; the GUI reads its setting back from a file it does not re-validate.

[2.16.1] - 2026-08-12

Fixed

  • Creating a new Stoat server no longer fails immediately. Any migration that did not reuse an existing server stopped at the start of the server phase with Phase server failed: '_id', before a single channel was made. Stoat replies to the create-server call with the server nested beside the channels it makes alongside it, and Ferry read the reply as though the server were at the top level. Point Ferry at a fresh server and it now proceeds. Migrations run with --server-id, and resumed or incremental runs carrying a server id, were never affected (#265).
  • ferry build, which creates a server from a blueprint or a preset template, read the same reply the same wrong way and failed the same way. Fixed with it.

[2.16.0] - 2026-08-12

Added

  • A new ferry check command verifies a migration you have already run. Point it at the output directory and it asks the Stoat server whether everything Ferry recorded is still there: every channel, role, category and emoji, and each channel's most recent messages. It is read-only and creates nothing. Run it after a migration, after a resume, or weeks later. It exits non-zero if anything is missing, so it can be used in a script (#107 batch 9).
  • Results come back as one of four answers, and the fourth matters. ok means found and matching, warn means a category was renamed but its contents are intact, fail means something is gone, and unverifiable means Ferry cannot answer and says why. A warn on its own does not fail the command.

Changed

  • Nothing existing changed behaviour. --validate-after still does what it did, and ferry validate still inspects an export before a migration rather than checking one afterwards.

Notes

  • What ferry check cannot tell you is documented alongside what it can. It reads the 100 most recent messages in each channel, so a gap in the middle of a channel is invisible and an ok means "the last message Ferry recorded is present" rather than "this channel is complete". A channel that has gained more than 100 messages since the migration reports unverifiable rather than guessing. A renamed channel or role is not detected, because Ferry never recorded the names it gave them; renamed categories are. See the Known Limitations guide.

[2.15.0] - 2026-08-11

Added

  • DiscordChatExporter updated from 2.47.1 to 2.47.3. The export step now streams large attachments to disk instead of holding them in memory, which fixes exports of big servers running out of memory partway through. Ferry downloads the new version automatically and verifies it, so most people have nothing to do. If you put a DCE binary in place by hand, move it: the folder Ferry looks in is now named 2.47.3.

Changed

  • Threads now begin with their real first message. Discord stores a thread's opening message in the parent channel and leaves only a placeholder at the top of the thread. Until now Ferry migrated that placeholder as a literal [empty message]. DCE 2.47.3 resolves it, so a migrated thread opens with the message that actually started it, in its right place in the conversation. Thread transcripts saved with --thread-strategy archive gain the same line.
  • --incremental now refuses to continue from a dry-run migration. A dry run records placeholder message ids rather than real ones, so continuing from it produced replies pointing at messages that were never sent. Start a fresh migration instead; the error says which file to remove.

Fixed

  • --thread-strategy merge no longer posts a thread's opening message twice. With that strategy a thread's messages are appended into its parent channel, and after the DiscordChatExporter update the thread's first message is one the parent channel had already sent. Ferry now recognises it and does not send it again. One case is still missed, and it needs all of these at once: the parent's send of that message succeeded, its reply was lost on the way back, and the retry reached Stoat while the original was still in Stoat's short-lived duplicate cache. Ferry then has no record of the message and sends the copy anyway (tracked in #240). Anyone using flatten, the default, was never affected.

[2.14.7] - 2026-08-11

Fixed

  • A message that Stoat had already accepted could be reported as failed, and a later --incremental run would then send it a second time, leaving two copies in the channel. This happened when a send succeeded but its reply was lost on the way back, for example on a flaky connection: Ferry retried, Stoat recognised the retry and refused it as a duplicate, and Ferry read that refusal as a failure. Ferry now recognises it as "already delivered", so the message is not recorded as failed and nothing re-sends it. Stoat does not say which message it was, so a message affected this way cannot be used as a reply target, and its pin and reactions are skipped; a warning names it in the report (#107 batch 7).
  • The forum index message is no longer reported as a failure when it turns out to have been posted already, and the index channel keeps its place in the migrated server rather than being left unlinked.

Changed

  • When a thread and its parent channel both refer to the same source message, the parent's copy is now the one Ferry records, consistently rather than depending on which channel happened to finish first. Replies and pins inside a channel now resolve against that channel's own messages first. This has no effect on the current DiscordChatExporter version and is groundwork for updating it (#110).

[2.14.6] - 2026-08-11

Fixed

  • A crash, a full disk, or a killed process partway through writing one of Ferry's own files could leave a half-written file where a complete one was expected, with the previous good copy already gone. The server blueprint is the one that mattered: Ferry reads that file back when you import it, so an incomplete export survived the run that produced it, and there was no way to tell it apart from a good one until the import failed or built a partial server. The files holding your migration's progress, the Discord server details it collected, its report and an exported blueprint are now written to a temporary file first and swapped into place, so a failure leaves the previous version readable. This also covers the backup Ferry takes before upgrading an older state file, which becomes the only copy of the original (#175).

[2.14.5] - 2026-08-11

Fixed

  • ferry tls-check could fail with a traceback instead of reporting, on a machine where the system proxy configuration could not be read. It now reports proxy-source: unreadable and exits normally. Reading the configuration involves the operating system, and Ferry does not control what that can return.

It also no longer reports a clean machine when it means it could not look. Those two are worth telling apart: one says there is no proxy, the other says the question could not be answered, and treating the second as the first is a diagnostic that lies. When one protocol resolves and the other cannot be read, the working one is still reported and the failing one is marked unreadable, so neither answer hides the other.

  • An export could stop before DiscordChatExporter started, for the same reason. Ferry passes the system proxy through to the exporter as a convenience, and a failure to read it now leaves the export running without it rather than ending it. If that happens the export log says so, so an export that quietly ignored a proxy no longer looks like an export that never needed one.

Nothing changes on a machine where the proxy configuration reads normally, and a proxy set through an environment variable is still passed through untouched.

[2.14.4] - 2026-08-11

Fixed

  • Migrations crashed on Windows with [WinError 183] at a checkpoint save. Ferry saves its checkpoint at every phase boundary and every 50 messages, writing to a temporary file and then swapping it over the previous one. That swap used a call that replaces the destination on macOS and Linux but refuses it on Windows, so the save died as soon as a checkpoint file already existed. A run into a fresh folder got as far as its second save, inside the server and channel phase; a run into a folder Ferry had already written to, which is what resume, --incremental and reusing an existing export all do, died at the first. Either way the migration ended early and could not be resumed. The three affected writers now overwrite on Windows as they always did elsewhere (#172).

Changed

  • CI now runs the state and metadata test suites on Windows. Nothing in this project's tests had ever executed on Windows, which is why #172 reached a user rather than a test run (#174).

[2.14.3] - 2026-08-10

Fixed

  • Rollback failures could write an unredacted proxy password into state.json. The five sites recording a failed delete redact through the per-config token store, which never holds the proxy password and header registered when a proxy is in use. Those values live in the process-wide registry instead, so they reached the file unmasked. This is the one change here that repairs shipped behaviour rather than adding depth.

Changed

  • report.json and state.json are now redacted where they are written, rather than at each of the roughly 59 sites that append a warning or an error. Redaction had to be remembered at every site, and it was not: four migrator modules applied it and two did not, leaving 18 places that interpolate a raw exception into a persisted note. report.json is the file the bug report template asks users to attach, so anything in it is effectively published.

Redaction covers the fields that carry an exception or a user-supplied string: warnings, errors, the failed-message error and content preview, the rollback failure error, and the source guild name. It deliberately does not cover the rest of either document. Identifier maps are never passed to the redactor, because masking works by substring replacement and a short proxy password would rewrite Discord and Stoat identifiers, including the values that drive resume. message_map.json is excluded for the same reason, and because scrubbing it measured 290ms per checkpoint at 100,000 messages against 3ms for everything else.

migration_report.md gets the same treatment. It is written beside the other two, carries the same warning and failed-message text, and had no redaction at all.

A guard test now fails the build when a new field appears in either JSON document, a new member key appears at any site that appends a warning or an error, or a field is added to the failed-message or rollback-failure records, until it is classified as text or structural. The member-key check reads the actual append sites rather than a list, so a new one cannot slip past it.

The wording of error messages is unchanged, and warnings keep their original text in memory, so what a message says while troubleshooting is unaffected. A secret inside one is now masked in all three files.

  • A four-release-old comment in the server structure phase said the Autumn upload service may echo the session token in an error body. That was never verified and is not true. Autumn returns only an error variant name, and Stoat only an error type and a source location. Neither echoes a request header. The comment is corrected, and the fixed template it justifies is kept for the reason that does hold: a connection error can carry proxy credentials.

[2.14.2] - 2026-08-10

Fixed

  • ferry probe --json could print output that a JSON parser rejects. The payload went through the module-level Rich console, which has soft_wrap=False and falls back to 80 columns when stdout is not a terminal. Rich wraps prose at the nearest space and has no idea it is holding JSON, so the inserted newline could land inside a string value. It now prints through click.echo, which does not wrap. probe --json is the only --json path in the CLI, so no other command had the same shape.

The existing test passed an empty probe report, whose payload is far under 80 columns and so never wrapped. The new test uses a realistic four-check report and parses stdout.

Changed

  • The four release.yml structural tests in tests/test_packaging.py now assert that each guarded branch reaches its exit 1, rather than only that the condition text is present. A condition is not an assertion: deleting the exit 1 turns the gate into a warning that never fails a build, and all four tests stayed green through exactly that change. Each now slices a bounded region after its own condition, so it stays tied to its own branch among the workflow's dozen-plus exit 1 lines. Verified by removing the exit 1 from each of the four branches in turn and confirming the matching test fails.

  • docs/architecture/ and scripts/check-deferral-fields.sh are now gitignored, joining .claude/, CLAUDE.md and docs/plans/. Both hold the local development instruction layer rather than anything the tool ships, and both are backed up outside this repo.

[2.14.1] - 2026-08-10

Fixed

  • The GUI could reach a state where migration was impossible. A rendered_markdown warning disabled Start Migration, the only route to the migration screen, with no override anywhere in the interface. The same export migrated normally through ferry migrate, which never consulted the warning. The reason now renders with a checkbox that enables the button. (#143)
  • The rendered-markdown check flagged any message with a non-empty mentions array and no raw <@ in its content, so a reply, a mention carried inside an embed, or an attachment-only message each condemned a whole channel. It now flags a message only when a mention's display form appears in the content as a whole token while its raw form does not, or when the content carries @Unknown, which is what the exporter writes for a member it could not resolve. It also reports how many messages are affected. Previously it stopped at the first. (#143)
  • ferry validate and the GUI now give the same explanation, which names what happens to the affected messages. The old wording pointed at a re-export flag that does not help in every case. ferry validate still exits 1.

[2.14.0] - 2026-08-10

Added

  • Ferry now resolves an HTTP or HTTPS proxy for every outbound session, closing issue #135. HTTP_PROXY, HTTPS_PROXY and NO_PROXY work the way most command-line tools already read them. On Windows and macOS, when those variables are unset, Ferry falls back to the operating system's own proxy configuration (the registry on Windows, the system configuration on macOS), so a proxy set only in system settings still applies. The two sources merge per scheme rather than one short-circuiting the other. NO_PROXY always exempts a host. The system's own exception list is honoured as well, for a proxy the system supplied, which is how curl behaves: a proxy you set yourself in the environment is not filtered by the machine's list.

Resolution installs through a ClientRequest subclass in core/http.py, the same session factory every outbound call already goes through for the v2.13.0 TLS trust policy. A proxy that requires authentication is handled on the request itself: Ferry strips the credential out of the URL, sends it as a Proxy-Authorization header, and registers both the plaintext and encoded forms for log redaction, so neither reaches ferry.log.

FERRY_DISABLE_PROXY=1 turns proxy use off everywhere, and ferry tls-check reports what Ferry actually resolved: proxy-http, proxy-https, proxy-source and proxy-disabled. Eight existing network error handlers, the same call sites v2.13.0 wired for certificate errors, now also recognize a proxy failure and name the proxy, the target host, and whether the proxy itself rejected the request.

DiscordChatExporter runs as a separate process and cannot see any of the above, so the export phase passes it an OS-resolved HTTPS_PROXY directly (only when the user has not already set one), with the credential left out: a password sitting in a child process's environment is readable by other processes on some systems. SOCKS proxies and a proxy set only through ALL_PROXY are outside what Ferry supports; Ferry reports them by name instead of connecting through them with no explanation, tracked as issue #141.

The aiohttp floor moves from >=3.9 to >=3.14. core/http.py imports encode_basic_auth, which aiohttp added in 3.14.0 as the replacement for BasicAuth and proxy_auth, both of which are deprecated for aiohttp 4.0. Anyone with a pinned aiohttp below 3.14 needs to raise it.

yarl and multidict are now direct dependencies. core/http.py imports yarl.URL and multidict.CIMultiDict directly, and both previously reached the environment only through aiohttp's own requirements, the same gap certifi closed in v2.13.0.

Documentation

  • Troubleshooting guide gains a Proxy Configuration section: the operating-system fallback, the difference between FERRY_DISABLE_PROXY and NO_PROXY, why the kill switch cannot fix a stale system proxy entry during export, why the GUI and ferry tls-check can disagree about a .env-only proxy, and the SOCKS, ALL_PROXY, and authenticating-proxy limits.

[2.13.1] - 2026-08-07

Fixed

  • A certificate failure while uploading a file to Autumn gave no reason (issue #137). v2.13.0 wired actionable guidance into six error handlers, but Autumn had none: upload_to_autumn let a ClientConnectorCertificateError propagate unwrapped, and structure.py's role-icon handler catches it as an OSError and discards the text on purpose, so the user saw a fixed template with no cause at all.

upload_to_autumn now converts a certificate failure into an AutumnUploadError naming the host and SSL_CERT_FILE, which covers all five callers at once. Every other ClientError is re-raised untouched, so no caller's except clause changes what it matches. The certificate case raises on the first attempt rather than paying two retry sleeps for an error no retry can clear.

The role-icon handler re-derives the hint from the __cause__ chain, since it discards the message. It still never interpolates the exception: the Autumn response body may echo x-session-token, and tls_hint emits only a host, a port and fixed text.

Reaches anyone on a self-hosted Stoat whose Autumn file host sits behind a different certificate authority than the API host.

[2.13.0] - 2026-08-07

Fixed

  • The packaged Windows binary could not verify the TLS certificate for api.github.com, so DiscordChatExporter never downloaded and the export never started (issue #134). Ferry inherited whatever trust the operating system offered, with no fallback and no documented override.

Every outbound session now goes through one factory in core/http.py that builds an SSL context trusting the union of the OS certificate store and certifi's bundled roots. All 8 previous session sites use it. Verification stays strict throughout: CERT_REQUIRED and hostname checking are on in both the union and fallback branches, so nothing in this change can turn verification off. certifi is now a direct dependency, and ferry.spec names it so cacert.pem reaches the frozen binary.

SSL_CERT_FILE already overrode the trust store before this change; it is now documented rather than newly supported.

Six existing certificate-error handlers now give actionable guidance instead of a raw traceback, each keeping its own exception type. Three retry loops recognize a certificate failure and stop instead of paying backoff a bad certificate cannot survive, and a certificate failure no longer primes the circuit breaker.

ferry tls-check reports the resolved bundle, whether it is readable, which trust branch was taken, and how many certificates are visible. The Windows release job now runs it against a real runner and asserts the union branch.

This does not identify what caused the original report's certificate failure. If ferry tls-check still shows a problem after upgrading, see the troubleshooting guide.

[2.12.1] - 2026-08-06

Security

  • The desktop app never called register_secret, so the Stoat token was not masked in ~/.discord-ferry/logs/ferry.log. Affects versions 2.11.1 and 2.12.0. Command-line users were not affected. _store_session_tokens now calls register_secret for both tokens.

If you ran the desktop app on those versions, check ~/.discord-ferry/logs/ferry.log for your Stoat token and rotate it if found.

bug_report.yml said tokens were always masked. It now gives the version floor.

  • aiohttp 3.14.1 to 3.14.3 in uv.lock, with no source change. This resolves three Dependabot alerts. Ferry calls aiohttp as a client. src/ contains no aiohttp.web code and no aiohttp websockets, and NiceGUI serves over uvicorn.
  • High, out-of-bounds heap read in the C HTTP response parser on a malformed chunked response. Ferry parses responses from Stoat, Autumn, the Discord API and the Discord CDN, all of which use this parser.
  • Medium, HTTP request smuggling via WebSocket upgrade. Server-side code only.
  • Medium, WebSocket client accepts compressed frames without a negotiated permessage-deflate. Needs an aiohttp websocket client.

Added

  • test_gui_token_entry_registers_both_tokens (SC-123-21) calls _store_session_tokens, logs both tokens, and reads the log file back. Removing the register_secret calls makes it fail.
  • test_rotation_bounds_the_file_count (SC-123-16) writes past the rotation threshold and checks the file count stays at _BACKUP_COUNT + 1. It fails when _BACKUP_COUNT is 0.

Fixed

  • auto-tag.yml now checks whether the current version is already tagged. It previously compared pyproject.toml at HEAD against HEAD~1. A rebase merge lands the version-bump commit in the middle of the pushed range, so both of those commits can already carry the new version, and the job then reported success without creating a tag. v2.12.0 reached main that way: no tag, no GitHub Release, no PyPI publish. A tag-existence check gives the same answer under squash, rebase and merge commits, and repairs a release that was missed earlier. The checkout now fetches tags as well; at fetch-depth: 2 it passed --no-tags, leaving the duplicate-tag guard with nothing to find.

  • install.md, first-migration.md and gui-walkthrough.md said a browser opens automatically. The packaged Windows and macOS apps bundle a window toolkit, so NiceGUI sets show=False and no browser is opened (issue #123). A pipx install does open a browser, because pywebview sits in the optional native extra. The pages now cover the two routes separately and give http://localhost:8765 as a supported way in.

Added

  • troubleshooting.md: entry for a blank window or the message "Your browser does not support ES modules", with the Edge WebView2 Runtime fix.
  • troubleshooting.md: entry for Ferry.exe ignoring --help on versions before 2.12.0.
  • cli-reference.md and install.md: the downloaded app takes commands. Both note the case where it opens the GUI because there is nowhere to write output.
  • Test covering where --version gets its value. The existing test asserted that the output contains discord_ferry.__version__ without pinning the source. Removing the explicit argument from click.version_option makes Click read importlib.metadata, which returns the same string in an editable install, so that assertion kept passing while a PyInstaller bundle reported the wrong version. The new test patches the module attribute to a sentinel and reloads cli.py.

Changed

  • bug_report.yml asks for the log file at ~/.discord-ferry/logs/ferry.log, added in 2.11.1. It previously asked for ferry-output/report.json, which cannot exist for a failure before the reporting stage. Tokens are stripped before anything is written to the log. The form also gains a "where did it stop?" field, and the version placeholder no longer reads 0.1.0.

[2.12.0] - 2026-08-06

Added

  • The packaged app now accepts command-line arguments. Ferry.exe --help, --version, and every existing command (migrate, validate, build, export-blueprint, stats, rollback, probe) work from PowerShell. Previously the binary ignored every argument and opened the GUI with no output (issue #123).

On Windows the .exe is the only artifact, and pipx install discord-ferry was the only alternative route to the CLI.

Because the app is windowed, output arrives after your shell has already printed its next prompt. When launched with no console at all, such as from a service, a scheduled task, or a file dropped onto the icon, it opens the GUI instead of running the command; the arguments passed to it are not acted on when that happens.

  • Added ferry --version.

[2.11.1] - 2026-08-06

Fixed

  • The one-click GUI migration could never start an export. On the Export screen it showed "Preparing…" with an empty log panel and no CPU, disk or network activity, indefinitely. DiscordChatExporter was never launched.

Every release from v2.6.14 to v2.11.0 is affected, on every platform. If you used the one-click (orchestrated) path in the GUI during that window and the export never started, this is why. The "I already have exports" path and the CLI were never affected.

The export ran in a background task, and its first statement asked NiceGUI for the current client. NiceGUI tracks that per asyncio task rather than through a context variable, so a background task starts with nothing attached and the lookup raised immediately — before a single line reached the log panel. Nothing surfaced the error, because the GUI binary is built without a console and no log file existed to catch it.

The page now resolves the client and the session tokens before starting the task, and the task re-enters the client's context for the UI calls it makes. The same fault silently suppressed the "Migration complete!" and "Rollback complete!" confirmations and aborted the rollback task early; all four background tasks are fixed together.

  • Waiting for the browser to connect is now bounded. These waits had no timeout, so a client that never completed its handshake would hang the page forever with no message. They now time out and say so, naming the log file.

  • A missing export directory now reports an error instead of vanishing. It previously raised outside the error handler, so the screen simply stopped.

  • Re-export is guarded. Repeated clicks no longer stack concurrent exports writing into the same directory, and a retry after the session token is cleared now returns you to setup rather than exporting with a stale token.

Added

  • A log file, always on, at ~/.discord-ferry/logs/ferry.log (2 MB, 3 rotations). The GUI is packaged without a console, so until now nothing that failed in the background left any trace anywhere — which is why the bug above survived eleven releases. Attach this file to bug reports.

Tokens are masked before anything is written — including values passed as deferred log arguments, and anything inside a traceback (exception messages routinely quote the token that caused the failure).

[2.11.0] - 2026-08-02

Fixed

  • A crash during --thread-strategy merge no longer duplicates thread messages on the next run.

The merge path wrote nothing to disk until the entire MESSAGES phase finished — not a single checkpoint across every thread. A crash, a force-quit or a lost connection partway through therefore discarded the record of everything already delivered into the parent channel, and the recovery run delivered it all again.

Merging now checkpoints inside each thread (on the same interval-and-time cadence the default flatten strategy has always used) and again when a thread completes. --resume reads the resulting marker, which it previously ignored for merged threads, so a resumed run continues from where the crash landed instead of restarting the thread. The thread separator is no longer re-posted either.

The larger the server, the worse this was: the window between "first message delivered" and "phase finished" is the entire merge.

A message that failed to send also advances the checkpoint, so a resumed merge can skip past a failure — exactly as the default flatten strategy already did. That message is not lost: it stays in failed_messages, is reported as a failure, and an --incremental run re-attempts it. --resume deliberately does not, because re-sending could duplicate a message that actually landed before its response was lost.

Changed

  • docs/reference/stoat-api-notes.md no longer claims Stoat's Idempotency-Key protects a re-run. It said a repeated key makes the server return the existing message, "which makes the MESSAGES phase safe to re-run". Both halves are false, and the claim was load-bearing — it was the stated reason resume was considered safe.

Stoat's store is a 1000-entry, in-memory, process-local LRU with no TTL, emptied on restart, and a repeat returns HTTP 409, not the original message. A thousand entries is seconds of migration traffic. What actually prevents duplicates is entirely client-side, and the page now says which mechanism covers which strategy — including that merge never writes message_map and so relies wholly on the markers above.

Documentation

  • New guide: "Was my earlier migration affected?" (docs/guides/earlier-migrations.md) — a disclosure covering the five silent bugs fixed between v2.8.2 and v2.10.0.

Every one of them failed without an error: discarded forwarded messages, sticker- and embed-only replies dropped alongside them, attachments rejected by the server because Ferry's own size check used binary megabytes where Stoat uses decimal, uploads abandoned during rate limiting, and voice channels that granted nobody the right to connect, speak or hear. A migration could report complete success and still be missing content, which is why nobody reported them.

The guide gives the exact migration_report.json searches that identify each one, and is explicit about the limits of recovery: neither --resume, --incremental, nor the retry machinery will backfill any of it — the affected content was recorded as warnings, never as failures, and sits below the high-water mark those modes skip. (The guide also notes that retry has no command-line surface today, so "just run ferry retry" was never an option either.) Permissions can be fixed in place; missing content currently cannot, short of migrating the export again into a fresh server. The planned repair tool is described honestly, including what it will not be able to do.

[2.10.0] - 2026-08-02

Fixed

  • Migrated voice channels now grant voice permissions. Nobody could connect, speak or hear in a migrated voice channel, because none of Discord's voice permissions had a mapping — they all translated to zero.

The permission map covered 17 Discord permissions, reaching 18 Stoat bits out of the 34 Stoat actually defines. Newly mapped: VIEW_AUDIT_LOG, STREAM, CONNECT, SPEAK, MUTE_MEMBERS, DEAFEN_MEMBERS, MOVE_MEMBERS, MODERATE_MEMBERS, BYPASS_SLOWMODE, MENTION_EVERYONE, and AssignRoles alongside the existing MANAGE_ROLES targets.

CONNECT maps to two Stoat bits, Connect and Listen. Stoat gates joining a voice channel on Connect but gates receiving anyone's audio or video on Listen, so granting Connect alone would still have left members in a silent room.

  • Migrated ADMINISTRATOR roles no longer lose permissions. The expansion applied to a Discord admin role was a hand-written list covering only the bits that happened to be mapped, so admins arrived without any voice, mention, timeout, audit-log or role-assignment rights. It is now derived from Stoat's full enum, which means extending the map can no longer leave it behind.

Changed

  • Roles that could mention @everyone in Discord can do so in Stoat. A code comment claimed Stoat had no equivalent permission and that the mapping was intentionally dropped. That was false — MentionEveryone is bit 37 and MentionRoles is bit 38. This restores what Discord granted at source, but it is a real new capability for those roles: review which roles hold "Mention @everyone" before migrating a large server.

  • docs/reference/stoat-api-notes.md now lists all 34 permission bits and states plainly that the authoritative source is stoatchat's ChannelPermission enum, not developers.stoat.chat. The page previously reproduced a 13-bit subset from that site, which is where the missing mappings came from in the first place.

[2.9.0] - 2026-08-02

Added

  • Forwarded messages are migrated instead of discarded. Their content, attachments, embeds and stickers now arrive, marked [forwarded].

Ferry skipped every forwarded message and logged it as a DiscordChatExporter limitation. That was true when written, and stopped being true in February 2026: DCE 2.47 added a forwardedMessage object carrying the whole payload, and Ferry has pinned 2.47.1 since. The data has been present in every export we produce and we were throwing it away.

If you migrated with an earlier version, those messages were lost. Re-running the migration against the same export recovers them — nothing needs re-exporting unless your export predates DCE 2.47.

This applies on every path that renders a message, including --thread-strategy merge (which builds its own content) and --thread-strategy archive (which writes markdown files) — under both of those a forward previously archived or sent as an empty message with no warning at all.

Two limits are worth knowing. Recovered content posts under the name of whoever forwarded it, because DCE's forwarded block carries no author field — the original writer is not in the export at all. And exports made before DCE 2.47 contain no forwarded content to recover; those are still skipped, now with a warning that names the cause and says re-exporting fixes it.

Fixed

  • Replies carrying only a sticker or only an embed were discarded as forwarded messages. The old detector keyed on empty content, which is not unique to forwards: a reply whose entire payload is a sticker has empty content, no attachments and a reference, matching the heuristic exactly. DCE's reference.type ("Default" or "Forward") distinguishes them properly, so the heuristic is now only a fallback for exports too old to carry that field. Separate silent data loss from the above, found while fixing it.
  • A recovered forward is no longer treated as a reply. A forward's reference points at the message it came from, which is not a "replying to" relationship, but the reply step treats any reference as one. That counted every forward toward the reply-fidelity figures in the migration report; made Stoat render an actual reply-quote whenever the source was itself part of the migration (the common case for forwards within one server); and appended [Replying to message in #…] beside the [forwarded] marker for a cross-channel source, so a single message claimed to be both.

[2.8.5] - 2026-08-02

Fixed

  • Five of the six upload size limits were too permissive, so oversized files were uploaded and then rejected by the server (uploader/autumn.py). Autumn's limits are decimal megabytes, as written in stoatchat's Revolt.toml, but ours were binary:
tag was now too big by
attachments 20,971,520 20,000,000 971,520
avatars 4,194,304 4,000,000 194,304
backgrounds 6,291,456 6,000,000 291,456
banners 6,291,456 6,000,000 291,456
emojis 512,000 500,000 12,000

TAG_SIZE_LIMITS is the pre-upload guard, so ours being looser than the server's meant a file in the gap band passed our check, was uploaded in full, and came back a 413. Worst for attachments, where the band is ~971 KB wide. Such files are now declined locally with an accurate message instead of costing the bandwidth first. icons was already correct — it had been diagnosed and fixed on its own, with a comment naming this exact trap, while the other five were left standing.

  • The probe's Autumn drift detector could not fail (migrator/probe.py). It read a tags key from the Autumn root, which returns only {"autumn": ..., "version": ...} — so it compared nothing and reported "matches assumptions" on every run. Worse than no check: it asserted the very thing it never tested, and that silence is why the five wrong limits above went unnoticed. It now reads features.limits.<new_user|default>.file_upload_size_limits from the Stoat root and reports a per-tier, per-tag diff. When the limits cannot be read at all it says so explicitly rather than passing.

It also now warns on the two cases it previously counted as clean passes: a tag we assume that the instance never advertises (unverified is not the same as matching — the original sin in miniature), and a bucket the instance advertises that we hold no limit for.

Security

  • ferry probe can no longer be aborted by a malformed instance response (migrator/probe.py). It parses JSON from a server it does not control, and run_probe wraps its four checks in try/finally with no except — so a single unexpected type (say features.limits.default arriving as a string rather than an object) raised an AttributeError that killed every remaining check and propagated to the caller. Truthy non-dicts were the dangerous shape, because the x or {} idiom does not catch them. Each hop of the parse is now type-guarded.

Changed

  • ferry probe reports Autumn reachability as its own check (autumn_reachable), separate from the limits diff, so an unreachable file server cannot mask the limits result — and a non-200 from it is now a failure rather than a silent "ok".
  • Size-limit messages are rendered in decimal MB to match the limits themselves. They divided by 1,048,576, which would have described the 20,000,000-byte cap as "limit: 19.1 MB".

Internal

  • The release workflow now builds on pull requests that touch the packaging path (ferry.spec or release.yml). The workflow_dispatch trigger added in 2.8.4 could never serve its stated purpose: GitHub only offers it from the default branch's copy of a workflow, and auto-tag.yml pushes the tag the instant a version bump lands on main, so there was no window between "dispatch available" and "tag created". A packaging failure would first have surfaced on an already-published tag. The release job stays gated on a tag ref, so these runs build and verify only.

[2.8.4] - 2026-08-02

Fixed

  • The macOS app could be force-quit as "not responding", leaving a window that said "Connection lost. Trying to reconnect…" forever. Two defects compounded.

ferry.spec built onefile, so the process macOS registered with LaunchServices was the PyInstaller bootloader — which unpacks ~200 MB to a temp directory (measured 18 seconds from double-click to window) and then waits in usleep(). macOS wrote a hang report against v2.7.1 (Event: hang, Duration: 99.18s, Unresponsive for 97 seconds), the Dock offered Force Quit, and launchd logged exited due to SIGTERM. macOS builds are now onedir: one process, and the window is serving in ~2 seconds.

Separately, NiceGUI's native mode runs the pywebview window as a daemon=True multiprocessing child. SIGTERM kills Python without running atexit, so multiprocessing._exit_function — the only thing that terminates daemon children — never ran, and the window outlived its server. Ferry now runs a bounded, idempotent teardown ladder (destroy → terminate → kill) from both the shutdown hook and a finally: around the server, so no window can outlive the process that feeds it. This matters beyond tidiness: a surviving child would otherwise hang the interpreter in multiprocessing's unbounded atexit join while still holding port 8765.

  • The macOS release archive would have shipped a broken bundle. The onedir .app contains 119 symlinks; zip -r follows them, producing a 91 MB archive instead of 45 MB and extracting real files where the code signature recorded links — which Gatekeeper reports as "Ferry.app is damaged". The release workflow now archives with ditto and verifies the extracted artifact (symlink count, size, codesign, bundled data files). It also gained workflow_dispatch, so packaging can be exercised before a tag exists rather than on one.

  • The GUI's folder picker had never worked in the packaged app. It called webview.windows[0] in the server process, but the window is created in a different process, so that list is always empty and every click raised IndexError into a "requires native mode" toast. It now goes through app.native.main_window, which marshals the dialog across the process boundary.

  • The socket.io heartbeat now tolerates a 12-second stall instead of 6 (reconnect_timeout=10.0), so a brief hiccup no longer flashes the reconnect banner, and uvicorn's graceful-shutdown wait is bounded at 1 second rather than unbounded. Interrupting Ferry with Ctrl-C shuts it down cleanly, leaving no stray processes and no traceback.

[2.8.3] - 2026-08-02

Fixed

  • Autumn retried uploads nine seconds too early after a rate-limit hit (uploader/autumn.py). Autumn sits behind the same rate-limit middleware as the Stoat API and advertises X-RateLimit-Reset-After: 10000 (milliseconds) on a 429 — verified live against cdn.stoatusercontent.com — but _retry_after_ms never read that header. A real Autumn 429 carries no body retry_after and no Retry-After, so every one of them fell through to the 1000 ms default and retried into a bucket that was still shut. With MAX_RETRIES = 3, all three attempts could burn inside a single 10-second window and the upload failed outright. This is the mirror image of the 2.8.2 defect: the same header and the same unit, but where migrator/api.py read it and over-waited by 6×, uploader/autumn.py never read it and under-waited by 10×. Neither client's tests knew the other existed.

Changed

  • Autumn's 429 backoff is now bounded to [0, 60] seconds (uploader/autumn.py), matching the cap migrator/api.py already applied. Flagged rather than shipped quietly because it changes behaviour for inputs no correct server sends: the delay is remote-supplied, and making the header load-bearing is what made that matter. A negative retry_after made asyncio.sleep return immediately and turned the retry loop hot; an X-RateLimit-Reset-After of 3600000 slept a literal hour.
  • Non-finite and boolean advertised delays are rejected rather than clamped (uploader/autumn.py), falling back to the 1000 ms default. A clamp alone cannot catch NaN: every comparison against it is False, so it survives min/max, and asyncio.sleep(nan) then never returns — one such response would hang an upload forever. (time.sleep(nan) raises ValueError; asyncio's silence here is CPython #105331.) It needs no malice to arrive: Python's json module parses bare NaN/Infinity by default, so {"retry_after": NaN} from a sloppy server reaches us as a float. Separately, bool is a subclass of int in Python, so a JSON true was accepted as a 1 ms delay — beneath the clamp's floor, and so effectively no backoff.

Internal

  • .gitignore now covers .worktrees/. The directory already existed at the repo root but was untracked and unignored, so anyone creating a git worktree there would have committed an entire second checkout into the repository.

[2.8.2] - 2026-08-02

Fixed

  • Stoat rate-limit recovery waited 60 seconds instead of ~10 (migrator/api.py). Stoat sends X-RateLimit-Reset-After in milliseconds — documented at developers.stoat.chat as "Milliseconds left until calls are replenished" — on every response including 429s. Ferry read it as seconds, so min(10000, 60) clamped to the 60-second cap on every rate-limit hit. Worst in the message phase, which hits Stoat's 10-per-10s bucket hardest. The correct body-retry_after path (already divided by 1000, and carrying the same value) had become unreachable because the header is checked first. This was a regression caused by upstream: the code was correct when written, before Stoat began exposing these headers.
  • A test was locking the bug in. test_429_x_ratelimit_reset_after asserted that a header value of "1.5" meant 1.5 seconds, so the wrong unit had test coverage — anyone who suspected the defect would have hit a red test and backed off. Corrected to "1500" -> 1.5s, preserving the test's original intent (header honoured even when the 429 body is unparseable HTML).

  • A negative advertised delay no longer disables backoff. A negative header or body value parses cleanly as a float, and asyncio.sleep() of a negative number returns immediately — so a hostile or buggy value would have turned the retry loop hot. Both delay paths are now floored at 0 alongside the existing 60-second ceiling.

Internal

  • The Stoat-side parser is renamed _stoat_rate_delay_seconds and documents its unit contract. discord/client.py contains a function that was identical in name, signature and body but whose header genuinely is delta-seconds; the two were indistinguishable on sight, and merging them would silently break one side by a factor of 1000. Discord's semantics are now pinned by test_discord_reset_after_header_is_seconds_not_milliseconds, which fails if that merge is ever attempted. Retry-After remains delta-seconds on both, for proxies in front of Stoat.

[2.8.1] - 2026-08-02

Fixed

  • Every published sdist since at least v2.6.17 has shipped the release binaries inside it. release.yml downloads the built artifacts into release-assets/ and only afterwards runs uv build, and with no [tool.hatch.build.targets.sdist] config hatchling swept that directory into the source distribution. The result on PyPI: a 91.5 MB .tar.gz against a 0.2 MB wheel, for 2.6.17, 2.7.0 and 2.7.1 alike.

Adding the third (Intel) artifact in v2.8.0 pushed the sdist past PyPI's 100 MB per-project limit, so the upload failed with 400 File too large — which is how a long-standing bug finally surfaced. The v2.8.0 GitHub Release itself published normally; only the PyPI step failed, so v2.8.0 exists on GitHub but not on PyPI.

Fixed by excluding release-assets from the sdist target and gitignoring the directory. The wheel was never affected — it is scoped to src/discord_ferry — so pip install discord-ferry always resolved to the 0.2 MB wheel on any normal platform. Verified by planting 137 MB of incompressible data in release-assets/ and rebuilding: 0 members leak, sdist stays at 0.7 MB.

[2.8.0] - 2026-08-01

Fixed

  • The documentation site never rendered its own markup. mkdocs.yml carried no markdown_extensions block, so admonition and pymdownx.tabbed were never enabled — Material for MkDocs does not turn them on implicitly. All 87 admonitions across the 15 published pages printed as literal !!! warning "…" text, and all 27 content tabs printed as literal === "macOS". On the installation page that meant the per-OS tabs did not exist: Windows, macOS and Linux instructions ran together on one page, so a Mac user read Windows steps first. toc and tables are listed alongside the new extensions so the full set the docs depend on is visible in one place, though both remain on by default.

  • macOS install instructions told users to do something Apple removed two years ago. The guide instructed right-click → Open; that bypass was removed in macOS 15 Sequoia (August 2024). Users following it found no such option, and the dialog they were left staring at offers only Done and a highlighted Move to Bin — the prominent button deletes the app. Rewritten around the System Settings → Privacy & Security → Open Anyway flow, with an explicit warning not to click Move to Bin, and a note that the button only appears after a blocked launch and expires after about an hour.

  • xattr -d com.apple.quarantine → xattr -dr in both install and troubleshooting guides. Without -r the flag is cleared from the bundle directory only while the executable inside stays quarantined, so the documented command silently did nothing for an .app.

  • Neither code-signing step in release.yml could ever have run. Both gated on if: env.<SECRET> != '' while defining that variable in the same step's env: block; a step cannot read its own step-level env from its own if:, so the condition was permanently false. The Windows guard now resolves a non-secret boolean at job level while the certificate itself stays scoped to the signing step, so the gate works without widening the secret to checkout and build steps.

  • README download table understated the artifact sizes by roughly half (~25 MB against a real 48 MB), and pointed Windows users at a filename the release does not publish.

Added

  • Intel Mac builds. release.yml's macOS job is now a matrix over macos-14 (arm64) and macos-15-intel (x86_64), publishing Ferry-macos-x86_64.zip alongside the existing Ferry-macos-arm64.zip. Releases were previously arm64-only, so Intel Macs could not run Ferry at all. macos-15-intel is the last x86_64 image GitHub offers and is supported through August 2027.

  • Troubleshooting entry for the actual dialog users hit — "Apple could not verify Ferry is free of malware" — including an explicit note that the right-click workaround found in older guides no longer works.

Known limitations

  • Ferry is still not notarized, so macOS continues to require the one-time Open Anyway approval. Clearing the warning outright requires a Developer ID certificate plus notarization, which needs a paid Apple Developer Program membership. The removed macOS signing step is marked in release.yml where that work belongs; note it also needs --options runtime and an entitlements plist, as signing alone does not satisfy Gatekeeper.

[2.7.1] - 2026-08-01

Security

  • Cleared the two open medium Dependabot alerts (#42, #43) — uv.lock-only, no source change. setuptools 82.0.1 → 83.0.0 (MANIFEST.in exclusion bypass in sdist via NFC/NFD Unicode normalization collision on macOS APFS/HFS+; transitive via pyinstaller + pyinstaller-hooks-contrib, dev extra) and pymdown-extensions 10.21.3 → 11.0.1 (path traversal in the b64 extension letting <img src> read files outside base_path; transitive via mkdocs-material, docs extra). Neither package ships in the installed wheel or the PyInstaller binary — exposure was limited to the CI/build host — but both are now off the alert board. The pymdown-extensions fix is a major bump that Dependabot would not raise automatically; mkdocs-material 9.7.6 accepts the v11 line, so no dependency override was needed.

Documentation

  • README and docs landing page gained a "Tunable performance" reliability bullet pointing at the v2.7.0 flags (concurrency, reaction mode, thread filtering, checkpointing).

Internal

  • .gitignore now covers .claude-session-lock, which sat untracked next to the already-ignored .claude/ directory and showed up in every git status.

[2.7.0] - 2026-07-06

Added

  • The seven internal settings are now user-configurable (closes #99). ferry migrate gains --reaction-mode [text|native|skip], --min-thread-messages (≥0), --checkpoint-interval (≥1), --max-concurrent-channels (≥1), --max-concurrent-requests (≥1), --skip-avatars, and --validate-after — wired through _common_options/_build_config with parse-time Choice/IntRange validation and unchanged defaults (text/0/50/3/5/off/off). The GUI's Advanced Options panel gains the same seven controls under Speed / Content / Safety group labels, persisted like the existing controls and normalised by a new _coerce_advanced_settings helper (clamps stale/out-of-range disk-backed storage values, handles ui.number's float-or-None, falls back to text on unknown reaction modes). Migrate's --max-concurrent-requests is unrelated to rollback's same-named delete-concurrency flag.
  • Official-service concurrency warning. Raising either concurrency value above its default while targeting api.stoat.chat prints a CLI warning / shows a GUI notify (informational, never blocks): the official rate limits make higher concurrency slower, not faster.

Fixed

  • max_concurrent_channels <= 0 no longer deadlocks the message phase. migrator/messages.py built asyncio.Semaphore(config.max_concurrent_channels) unguarded — Semaphore(0) admits no worker and hangs forever. Now clamped max(x, 1), matching the existing defensive clamps for checkpoint_interval and max_concurrent_requests. Locked by a wait_for-bounded regression test.

Documentation

  • CLI reference: the "Internal defaults (not currently configurable)" section is gone — the seven settings are documented as real migrate flags. gui-walkthrough documents the 16-control Advanced Options panel. large-servers, pre-flight-checklist, and self-hosted-tips regain the concurrency/reaction/checkpoint/thread-filter tuning advice that PR #98 had to remove, now phrased against real flags.
  • User-facing docs caught up with the code (v2.2.3 → v2.6.17) (shipped unversioned as PR #98, folded into this release). The README, CLI reference, and guides had drifted ~14 releases behind. Highlights: the ferry probe command and post-migration invite generation are now documented; the "what gets migrated" tables include the native-fidelity work (role hoist/icons/live discovery, server description & NSFW, category ordering, slowmode, voice user limits); "role icons not migrated" and "slowmode not supported" removed from known limitations (both migrated since v2.5.0); all references to GUI Advanced Options controls that do not exist (reaction mode, min thread messages, checkpoint interval, concurrency, skip avatars, validate after) replaced with an honest "internal defaults, not currently configurable" note; wrong report filename report.json corrected to migration_report.json; export-blueprint options corrected (--output is required; --name exists); README gained badges and a section on the non-migration commands.

Internal

  • Removed dead _patch_checksums test helper (tests/test_exporter_manager.py). It was defined but never called (every TestVerifyDceChecksum test inlines its own patch("importlib.resources.files")), and was broken anyway — it built a _Ctx context manager then ignored it and returned a fresh, unconfigured patcher, papered over with # type: ignore[return]. No behavior change; suite stays at 926 passed.

[2.6.17] - 2026-06-28

Security

Cleared 3 high-severity Dependabot alerts — denial-of-service vulnerabilities in transitive dependencies pulled by nicegui[asyncio-client] (the GUI shell). uv.lock-only bump; no pyproject.toml, source, or API change.

  • python-socketio 5.16.1 → 5.16.3 — binary-attachment accumulation DoS (GHSA-5w7q-77mv-v69f, patched 5.16.2).
  • python-engineio 4.13.1 → 4.13.3 — unbound thread allocation DoS (GHSA-cgwc-pv48-fhj5, patched 4.13.2) + maximum payload size sometimes not enforced DoS (GHSA-m9gh-vj53-gvh9, patched 4.13.2).

[2.6.16] - 2026-06-28

Fixed

Defense-in-depth & misc lows — the tenth and FINAL batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 10), closing the hunt. Four latent-gap fixes; no GUI/CLI/state-schema/API change.

  • Engine exception strings are token-redacted everywhere they are emitted or persisted (core/engine.py). A new _safe(config, text) helper wraps every raw-exception interpolation in the engine event stream and in persisted state.warnings / RollbackFailure.error — honoring safe_sanitize's persist-or-emit contract. Previously only two sites were sanitized, leaving a latent path for a future token-in-URL or echoed-4xx-body to leak a credential to the GUI/CLI log or state.json. A new _ensure_token_store helper wires the token store in both run_migration and run_rollback (the shells call rollback directly and never set it), so the rollback-path redaction is actually effective. No behaviour change when no token store is set.
  • Concurrent uploads of the same file no longer duplicate (uploader/autumn.py). upload_with_cache had a check-then-act race: two parallel channel workers requesting the same physical file (an author's avatar / identical attachment) both missed the cache and both uploaded, wasting the per-tag Autumn budget and orphaning a file. A self-cleaning in-flight asyncio.Future registry now coalesces concurrent same-key callers onto a single upload. A failed upload is not cached and does not poison the key (a later run retries); concurrent same-key callers share the originator's outcome.
  • Avatar checkpoint cadence is now time-based (migrator/avatars.py). The phase saved state only every 10th upload, so a sub-10 tail (and a hard-kill between two saves) lost up to 9 avatar_cache entries, re-uploading those avatars as untracked orphans on resume. It now saves on a 5s wall-clock throttle (mirroring messages.py), bounding crash loss by time.
  • Repaired a dead test assertion (tests/test_thread_strategy.py). test_merge_mode_separator_sent defined a capture callback that aioresponses never invoked, so the thread separator payload was never actually asserted. Converted to the _capture_keys idiom asserting the separator's idempotency key — a broken separator send now fails the test.

[2.6.15] - 2026-06-28

Fixed

Parser / exporter / sanitize correctness sweep — the ninth batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 9). Ten confirmed silent-correctness defects, grouped into five engine-side fixes; no GUI/CLI/state-schema/API change.

  • Bold runs are no longer corrupted by underline stripping (parser/transforms.py). strip_underline collapsed every **** in a message — so two adjacent bold spans (**a****b**) lost a delimiter (**a**b**), and **** inside code spans was mangled. It now converts the two nested bold+underline forms explicitly and drops the blind global collapse, leaving pre-existing bold and code spans intact.
  • Timezone-naive timestamps are read as UTC (parser/transforms.py). format_original_timestamp treated an offset-less DCE timestamp as host-local time (shifting the hour) and crashed on a malformed string; it now assumes UTC for naive inputs and falls back to the raw string on a parse error.
  • Channels are no longer misclassified as threads (parser/dce_parser.py). _infer_thread_info treated any Guild - Channel - Thread-shaped filename as a thread, so a guild or channel name containing - mis-tagged a normal channel as a thread (corrupting category placement, merge/ skip-threads handling, and forum grouping — and potentially dropping messages). It is now authoritative on the parsed Discord channel type (10/11/12 = thread). The dead _TWO_SEGMENT_RE regex was removed.
  • A huge DCE stderr line no longer crashes the exporter (exporter/runner.py). _read_stderr used async for, which raised an uncaught ValueError on a stderr line over 64 KiB — stopping stderr capture, discarding the fatal line (reported as "Unknown error"), and leaking a "Task exception never retrieved". It now mirrors the stdout drain loop (truncating the over-long line). Cancellation is also checked during a long drain, and over-long lines now count as activity (no false "no new output" heartbeats).
  • Emoji name de-duplication no longer collides (migrator/sanitize.py). sanitize_emoji_name truncated the disambiguating suffix off at the 32-char limit (returning a duplicate) and never registered the generated name (so a later identical name re-collided). It now truncates the base to make room for the suffix, registers the emitted name, and bumps until unique.
  • Six dropped role permissions are now migrated (discord/permissions.py). DISCORD_TO_STOAT omitted Discord permissions that have Stoat equivalents, so migrated roles silently lost invite / kick / ban / manage-webhooks / change-nickname / manage-nicknames authority. Those six are now mapped (and added to the admin-grants-all set). (Discord's MENTION_EVERYONE has no Stoat Permission equivalent and remains intentionally dropped.)

[2.6.14] - 2026-06-28

Fixed

GUI lifecycle — the eighth batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 8). Two GUI-shell-only fixes in gui.py; no engine/parser/CLI/state-schema changes. Both close documented residuals of the v2.6.1 token-lifecycle rework.

  • The cached-export choice is now honoured (gui.py, S1/F8a). On /export, the "Use Cached"/"Re-export" choice card was inert: background_tasks.create(_run_export()) ran unconditionally on page load, racing the click — re-validating the token, re-downloading DCE, re-running the export and overwriting the cached JSON, then navigating away. The auto-launch is now gated behind _should_auto_export(cached) (fires only when no cache exists); "Use Cached" goes straight to /validate (and clears the now-unneeded Discord token — no skip-path leak), while "Re-export" launches the export explicitly. The cached fast-path works and the cached JSON is no longer clobbered.

  • Session tokens are never persisted to disk (gui.py, S2/F8b, security). The Stoat and Discord tokens were written to app.storage.user, which NiceGUI persists to .nicegui/storage-user.json on disk; the only clear ran at the terminal migration screen, so abandoning the flow (or a crash) before migration started stranded a plaintext token on disk — and the setup form even pre-filled the token fields from that on-disk copy. Both token values now live only in NiceGUI's memory-only per-tab store (app.storage.tab): never written to disk, dropped when the tab closes, and surviving in-tab navigation so the setup→export→validate→migrate handoff is preserved. This closes the abandonment window in every mode, including a hard crash. The token input fields no longer pre-fill (re-enter each launch — the security.md-aligned norm), a setup-load scrub removes any token left on disk by a pre-fix version, and missing-token restart edges bounce cleanly to setup with a "session expired" notice. Engine/shell separation is unchanged (the engine never reads app.storage).

[2.6.13] - 2026-06-27

Fixed

Incremental edge cases — the seventh batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 7). Three engine-side fixes (migrator/messages.py, migrator/structure.py, reporter.py); no GUI/CLI/state-schema changes. All three are follow-up gaps in/around the v2.6.x incremental machinery (durable channel_high_water v2.6.3, the #76 failed-message self-heal v2.6.4, reporting-integrity v2.6.2).

  • Merge-strategy thread failures are no longer permanently lost (migrator/messages.py, S1/F7a). In thread_strategy="merge", a message whose POST failed was recorded only as a warning, never a FailedMessage, while the high-water marker was written regardless — so on a later --incremental run the failed id sat below the marker and was skipped forever, unrecoverable by ferry retry. The shipped #76 self-heal is now ported into the merge loop: a POST failure is recorded as a FailedMessage (in all modes); on --incremental a prior-failed id is excluded from the skip gate and re-attempted; a completion reconciliation drops ids that succeeded this run and collapses a carried + re-fail duplicate to one entry. The high-water marker write is unchanged (plain/resume byte-identical). Both merge warnings (separator + message) are now sanitized via safe_sanitize so a token-bearing exception is never persisted to state.json. Known limitation: the --incremental re-attempt is the idempotency-safe primary recovery; recovering a partial-success multi-part merge message via ferry retry may re-send already-delivered parts (the ferry-merge- vs ferry- idempotency-namespace difference) — a proper fix needs the merge context threaded into the retry engine and is out of scope for this batch.
  • Incremental category sync no longer transiently deletes carried categories (migrator/structure.py, S2/F7b). run_categories built its early full-replace category PATCH from only this run's (partial) export, so in incremental mode introducing a new category deleted carried-only categories until the CHANNELS phase re-PATCHed — a window where a crash/cancel left the live server wrong. The early upsert is now skipped in incremental mode; run_channels' authoritative upsert (which enumerates the full category_map) is the single source of truth. Fresh (non-incremental) runs are unchanged.
  • Fidelity scores can no longer render negative (reporter.py, S3/F7c). In incremental mode the messages denominator is this run's partial source_messages_total while failed_count is carried cumulatively, so compute_fidelity_score could emit a negative Messages percentage (e.g. -60%) and depress the overall score. All five ratios are now clamped to [0, 1] via a _clamp01 helper — a no-op for in-range inputs. The single helper change also fixes ferry stats (it delegates to the same function).

[2.6.12] - 2026-06-27

Fixed

Rate-limit hardening — the sixth batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 6). Two engine-side HTTP clients (migrator/api.py, discord/client.py); no GUI/CLI/state changes. Root cause: await resp.json() was called unguarded inside the 429 (and Stoat's 2xx) branch, so a non-JSON body from a proxy/CDN (Cloudflare HTML) raised aiohttp.ContentTypeError — a ClientError subclass — that escaped into the generic network-error path.

  • A rate-limited Stoat request no longer trips the circuit breaker (migrator/api.py, S1/F6a). A 429 with a non-JSON body fell through to the network-error handler, incremented consecutive_failures, and ignored the server's Retry-After — violating the code's own "429 is not a circuit-breaker failure" invariant. The 429 delay is now resolved by a content-type-guarded helper that honours the Retry-After / X-RateLimit-Reset-After headers (seconds) first, then the JSON body retry_after (Stoat milliseconds), then a 1s fallback — all capped at 60s. A 429 now never primes the breaker, even when it exhausts the retry budget (the final-attempt failure bump is skipped for 429); a 5xx / genuine network error still primes it.
  • A non-JSON Stoat 2xx now fails with a clear, correctly-classified error (migrator/api.py, S2/F6b). A 200/201 with an HTML body was retried and re-raised as "Network error after 3 retries", hiding the real cause and mis-routing rollback classification. It now raises a distinct MigrationError naming the content-type; the "Network error after 3 retries" message is preserved for genuine connection errors.
  • Discord rate limits are honoured with a separate, bounded budget (discord/client.py, S3/F6c). Both metadata getters read the 429 delay from the JSON body and shared the 3-attempt network-retry budget, so a Cloudflare HTML 429 was retried with a fixed 1s (ignoring a 60s+ Retry-After) and three such retries aborted the metadata-fetch phase. The two byte-identical getters are unified into one _discord_request that honours the Retry-After header, retries 429s on a separate bounded budget (_MAX_429_RETRIES) that no longer consumes the network-error budget, and content-type-guards both the 429 and the 200 body parse.

Behaviour note: behaviour-preserving for well-formed JSON responses and genuine network errors (the "Network error after 3 retries" message is unchanged for real ClientErrors). The change makes the clients robust to non-JSON 429/2xx bodies from reverse proxies/CDNs and ensures the server's Retry-After is respected. The circuit-breaker thresholds and the adaptive rate-multiplier are unchanged. As a side benefit, an exhausted HTML-429 that was previously misclassified as a network error (no HTTP status) is now correctly classified as a 429.

[2.6.11] - 2026-06-27

Fixed

CLI display & blueprint round-trip robustness — the fifth batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 5). All in the CLI shell (cli.py); the engine, migrators, GUI shell, and blueprint module are unchanged (the GUI shell was audited and uses no Rich markup and has no blueprint-build path, so none of these findings apply to it).

  • A markup-hostile channel/server/guild name can no longer abort a migration (cli.py, S1/F5a). Console renders with markup enabled, so a Discord name containing Rich metacharacters (spam[/], [bold]news, an unbalanced ]) interpolated into a progress line raised rich.errors.MarkupError. Because _ProgressTracker.on_event runs synchronously inside the engine's emit, that exception unwound into the engine and was re-raised as a MigrationError, aborting an otherwise-healthy migration over a cosmetic display string. Every user-controlled value (event message, channel name, server/guild name, warnings, exception text, rollback suspect names, failure errors, the ferry stats error/warning previews) is now escaped via a new _safe() helper before markup interpolation; static format tags and integer/ID values are left live. A narrow except MarkupError guard around each tracker's render body is a defense-in-depth net (it falls back to an unstyled print and never swallows control flow — the rollback confirm/pause path is deliberately kept outside it).
  • build no longer aborts mid-build and orphans a server on a voice-channel failure (cli.py, S2/F5b). The blueprint build command POSTed channel_type="Voice" with no error handling; a voice-create failure (Stoat "Bug #194") propagated and sys.exited after the server, roles, and earlier channels were already created — leaving an orphan server with no rollback. A new _build_blueprint_channel() helper mirrors the main migration path's voice→Text fallback (retry the channel as Text with a warning; non-voice errors still propagate); both build loops use it, and the categorized loop preserves the recovered channel's id into its category. This also un-breaks ferry build --template gaming|community|education, whose shipped templates contain Voice channels.
  • build now replays a blueprint's role hierarchy (cli.py, S4/F5d). BlueprintRole.rank survived the export→import JSON round-trip but the build role loop applied only colour and permissions, so every built role got Stoat's default rank and the hierarchy was silently lost. The role loop applied colour via api_edit_role but sent rank through the same endpoint, where Stoat discards it; role ordering on the build path remained broken until #387. This also restores the shipped templates' role ranks (Admin/Moderator/Member ordering).
  • export-blueprint is unchanged (S3/F5c, decision A1). A Discord voice channel is still exported as type="Voice", consistent with the migration path and the shipped templates; S2's new build fallback makes that value build-safe.

Internal: the build command's six Stoat API helpers were hoisted from a function-local import to module level (no import-cost change — the module already loads them via run_migration), giving the new module-level channel helper access to them and a single uniform mock-patch target for tests.

Behaviour note: S1 is a robustness fix (a hostile name no longer crashes the run) and is otherwise display-preserving; S2 narrows the abort surface (voice failures now recover; all other errors behave as before). Known limitations: (1) a hand-authored blueprint that sets an explicit rank: 0 will not have that rank replayed — rank 0 is treated as "unranked" and left at Stoat's default (the shipped templates all use rank ≥ 1, and export-blueprint never emits roles, so neither is affected); (2) a non-voice channel-create failure during build still aborts mid-build and leaves a partial server (pre-existing behaviour, unchanged — S2 only recovers the voice-create case; orphan-server teardown is out of scope).

[2.6.10] - 2026-06-27

Fixed

Reaction/emoji fidelity-visibility cluster — the fourth batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 4). All in migrator/emoji.py, migrator/reactions.py, migrator/messages.py, reporter.py, stats.py, and state.py. Theme: reactions/emoji were dropped correctly (per Stoat's caps / missing assets) but with no durable signal, so the reaction fidelity score reported 100% while reactions were silently lost.

  • Unmapped-emoji reactions are no longer silently dropped (migrator/messages.py). A native-mode custom reaction whose emoji never entered emoji_map (no uploadable asset, or beyond the emoji cap) hit a branch with no else — the reaction vanished with no counter or warning. It now increments a durable reactions_dropped counter (on the per-channel ChannelResult, folded into state; the retry/direct-state path writes state directly) and records a token-safe unmapped_emoji_reaction warning.
  • Emoji discovery upgrades a stranded record on a better-asset re-encounter (migrator/emoji.py). First-seen-wins discovery stored image_url='' for an emoji first seen in message content/embeds; a later reaction carrying the real downloaded asset was discarded (the emoji was then skipped at upload and never entered emoji_map, compounding the dropped-reaction loss). A new _record_emoji helper upgrades the stored record in-place when the stored image_url is empty and the new source has a usable path (never downgrades), across all three discovery sources.
  • Emoji-cap truncation ranks by uploadability then usage, not a lexicographic accident (migrator/emoji.py). When more than max_emoji unique emoji are found, the kept subset was the first N by str(id) — an arbitrary lexicographic slice that could drop high-traffic emoji while keeping rare ones. The kept set is now ranked uploadable-first (an asset-less emoji can never occupy a slot ahead of a creatable one), then by occurrence frequency, with a non-numeric-id-safe tie-break; the truncation warning names the dropped emoji. A _EmojiRecord TypedDict keeps the occurrence tally mypy --strict-clean.
  • Cap-skipped reactions are counted in the fidelity denominator (migrator/reactions.py). At Stoat's 20-reactions-per-message hard cap the loop emitted an ephemeral warning then continued without counting, so cap-skipped reactions were in neither reactions_applied nor pending_reactions — invisible to the score. A durable reactions_capped counter now records them.
  • Reaction fidelity now reflects capped + dropped reactions (reporter.py, stats.py). The reaction denominator (reactions_total) is assembled at the report call sites as reactions_applied + reactions_capped + reactions_dropped + len(pending_reactions) (no compute_fidelity_score signature change). This also fixes the stats.py None-gate: a run whose only reactions were dropped (applied 0, pending empty, dropped > 0) now reports 0% reaction fidelity instead of "N/A". Two new persisted MigrationState counters (reactions_capped, reactions_dropped) deserialise from old state.json as 0.

Behaviour note: this is a reporting change, not a migration-behaviour change — the same reactions/emoji are migrated/dropped as before (Stoat's 20-reaction and 100-emoji caps are unchanged); the reaction fidelity percentage now reflects the loss instead of hiding it, and the emoji kept under the cap are chosen by uploadability + usage rather than id order.

[2.6.9] - 2026-06-26

Fixed

Message-phase control-flow cluster — the third batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 3). All in migrator/messages.py and core/engine.py. Common root: asyncio.gather(*tasks, return_exceptions=True) collapsed every BaseException (including asyncio.CancelledError) into a swallowed warning, and the _process_message retry path returned instead of re-raising.

  • ferry retry no longer hangs or silently drops a still-failing message (migrator/messages.py, core/engine.py). On the retry path (_process_message called with channel_result=None), a send failure was caught, appended to state.failed_messages, and the function returned without re-raising. The retry loop for fm in state.failed_messages then (a) appended to the very list it was iterating → mutate-during-iteration → an infinite loop on a deterministically-failing message, and (b) counted the re-failure as retried (reported success) and dropped it. _process_message now re-raises on failure only when channel_result is None (the parallel per-channel path keeps degrade-in-loop), and run_retry_failed iterates a list(...) snapshot — so the retry terminates, keeps the message failed with retry_count incremented, and accounts it correctly.
  • User cancellation during the parallel message phase is now handled cleanly (migrator/messages.py). gather(..., return_exceptions=True) returned a worker's asyncio.CancelledError as a result item, which the result loop logged as a channel_worker_failed warning before emitting the phase completed event — so the engine's clean-cancel handler never ran. The result loop now detects CancelledError first (it is a BaseException, not an Exception) and re-raises asyncio.CancelledError after checkpointing the channels that finished before the cancel, so the engine saves state and reports "Cancelled during messages". The in-loop cancel check also raises instead of break-ing, so a cancelled-mid-channel worker no longer falls through to self-marking the channel complete (which would lose its un-sent tail on --resume).
  • A channel worker that crashes before its per-message loop is no longer silently lost (migrator/messages.py, core/engine.py). Such a crash was recorded as a warning while the phase reported completed and current_phase advanced past messages, so on --resume the channel was skipped — never migrated, never retried, no DLQ entry. The result loop now collects the first non-cancel exception and re-raises it after checkpointing the successful channels, so the phase fails with current_phase still "messages"; --resume then re-runs the crashed channel while already-completed channels skip via completed_channel_ids. All surfaced failure strings are sanitised once via safe_sanitize and reused for both the persisted error and the emitted event (no raw exception/token in the event).

Behaviour change: a channel worker raising an unexpected exception before sending any message now aborts the run (resumable via --resume) instead of degrading to a warning and reporting the phase completed. This trades a silent, unrecoverable per-channel data loss for a surfaced, resumable failure. A single message failing inside a channel's send loop still degrades-in-loop (recorded as a FailedMessage, channel continues) exactly as before.

[2.6.8] - 2026-06-26

Fixed

Resume & migration-lock integrity cluster — the second batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 2). All in core/engine.py, migrator/structure.py, and state.py.

  • The advisory migration lock now survives the SERVER phase (core/engine.py, migrator/structure.py). The S17 lock is a [FERRY_LOCK:{ts}:{host}] marker appended to the live server description by _acquire_migration_lock; the SERVER phase then PATCHed the description with the Discord guild description — a full-field replacement that wiped the marker, so the lock protected only the connect+server phases and a second concurrent existing-server migration saw no lock during the hours-long message/reaction/pin tail. The engine now stashes the marker it wrote into a transient state.migration_lock_marker (never persisted, re-acquired each run), and run_server folds it into the description it already PATCHes — no extra API call. _release_migration_lock clears the field.
  • A --resume after a kill mid-SERVER no longer creates a duplicate server (migrator/structure.py). run_server set state.stoat_server_id from api_create_server but only the engine's post-phase save persisted it; a kill in that window left an on-disk state with no server id, so --resume re-entered the create branch and made a second server (orphaning the first with all its channels/roles). The id is now persisted with save_state immediately after creation, so --resume takes the reuse branch.
  • run_roles resume now finalizes attributes and permissions for roles created before a crash (migrator/structure.py, state.py, core/engine.py). pre_existing_role_ids = set(role_map) (captured at phase start) gated all three role passes, so on resume the roles created in the prior run were treated as pre-existing and their rank/hoist/icon/permissions were never applied. A new persisted state.roles_finalized set now gates the attributes + permissions passes (the create pass still gates on pre_existing_role_ids to avoid duplicate creation); roles are marked finalized at the end of a completed run_roles (regardless of the metadata-gated permissions pass), so a crash before the end re-runs both passes idempotently on resume. roles_finalized is carried into --incremental state and back-compat-seeded from role_map for migrations completed by an older version, so incremental runs still skip re-editing. A periodic intra-phase save in the create loop gives hard-kill durability.

[2.6.7] - 2026-06-25

Fixed

Autumn upload robustness cluster — the first batch from the 2026-06-25 v2.6.6 whole-codebase bug-hunt (docs/plans/audits/2026-06-25-bug-hunt.md, §4 Batch 1). All in uploader/autumn.py and its callers.

  • A malformed Autumn 200 no longer aborts the migration with a raw exception (uploader/autumn.py). The success path did await response.json() + result["id"], so a non-JSON 200 (reverse-proxy HTML, empty body) raised a raw aiohttp.ContentTypeError / KeyError, and an empty body would raise TypeError. Because _resolve_role_icon (migrator/structure.py) caught only AutumnUploadError, such a raw error escaped its guard and aborted the entire migration for any server with custom role icons. The body is now parsed with json(content_type=None) inside try/except (aiohttp.ClientError, ValueError) and an explicit isinstance(result, dict) and "id" in result check; every malformed 200 raises AutumnUploadError (carrying only the tag — never the response body or token).
  • --verify-uploads now actually detects a corrupt upload (uploader/autumn.py). On a present-and-mismatched server size, upload_to_autumn previously popped the cache entry but still returned the bad id, which upload_with_cache immediately re-inserted — the whole feature was a no-op that propagated the corrupt id forever. A size mismatch is now treated as a failed upload (raises AutumnUploadError; never returned, never cached). The now-dead cache parameter was removed from upload_to_autumn.
  • A non-JSON 429 no longer crashes the retry path, and Retry-After is honoured (uploader/autumn.py). The 429 branch did await response.json() assuming a JSON body; an HTML 429 from a reverse proxy raised ContentTypeError that bypassed backoff and (in the sticker/attachment loop) dropped the media. A new module-private _retry_after_ms computes the backoff defensively: body retry_after (ms) → Retry-After header (integer seconds) → 1000 ms default, tolerant of any non-JSON body.
  • Role-icon upload failures degrade instead of aborting (migrator/structure.py). _resolve_role_icon's guard is broadened to except (AutumnUploadError, OSError) (also covering the temp-file write), so an icon that can't be uploaded is skipped with a token-safe warning and the roles phase continues — rank/hoist for that role still apply.
  • Sticker and embed-media uploads reach attachment parity (migrator/messages.py). Both now pass verify_size=config.verify_uploads and register in state.autumn_uploads like regular attachments. The embed media_id is additionally credited to referenced_autumn_ids on a successful send (but not added to autumn_ids, so it does not consume the 5-attachment cap) — without this it would have been reported as a false orphan on every migrated embed image.

[2.6.6] - 2026-06-25

Fixed

  • Empty-message fallback no longer silently clobbers poll/sticker/placeholder/embed-note bodies (migrator/messages.py). From the 2026-06-24 v2.5.1 bug-hunt refresh (HIGH-severity silent content data loss). The Step 6 empty-message guard tested the raw msg.content == "", but _build_content and the caller append several optional bodies into the built content before that guard — poll text (flatten_poll), sticker text (handle_stickers), oversized/expired-attachment placeholders, and the "[N embed(s) could not be migrated]" note. A message whose entire body came from one of those paths (e.g. a poll-only or sticker-only message, which has empty raw content) was therefore overwritten with [empty message] — silent loss the user never saw. The guard now tests the built content: it compares content.strip() against a reconstructed empty baseline (the timestamp prefix plus, for edited messages, the *(edited)* marker), so any pre-guard append path is preserved. The not autumn_ids and not stoat_embeds conditions are retained, so attachment-only and embed-only messages are still not labeled empty, and a whitespace-only message is now (correctly) treated as empty. Empty edited messages are relabeled [empty message] *(edited)* instead of dropping the edit marker. The " *(edited)*" literal was extracted into a shared _EDITED_MARKER constant so _build_content and the guard stay byte-identical.

[2.6.5] - 2026-06-25

Fixed

  • Thread merge strategy now honours the durable high-water marker (migrator/messages.py). Closes GitHub #78 (finding M2). The v2.6.3 incremental gate covered the default flatten strategy (via _process_single_channel), but thread_strategy="merge" runs a separate function (_merge_threads) that re-POSTed the separator + every thread message into the parent channel on every --incremental run — idempotency-keyed so it never duplicated content, but wasted O(all-thread-messages) HTTP work each run. _merge_threads now: skips the separator and already-copied messages on --incremental (gated on config.incremental), tracks the thread's max message id (over all messages incl. system/skip-type ones, isdigit()-guarded), and writes a per-thread channel_high_water marker at completion (every run, so a first full run sets it up). An unchanged merged thread therefore makes zero POSTs on an incremental run, matching flatten; a thread with K new messages re-POSTs only those K (no re-sent separator). flatten, archive, and --resume behaviour are unchanged. The per-channel completion event now reports the count actually posted this run.

[2.6.4] - 2026-06-25

Fixed

  • Incremental mode now self-heals previously-failed messages, and a non-numeric carried offset no longer crashes the skip gate (migrator/messages.py, core/engine.py). Closes the v2.6.3 known limitation (GitHub #76) and a folded-in pre-existing crash (GitHub #77), both surfaced by the whole-branch review of the v2.6.3 work.
  • A message that failed to POST on a prior run is now re-attempted automatically on the next --incremental run (#76). The durable channel_high_water mark tracks the highest message id seen, not successfully copied, so a failed id sat below the mark and was skipped forever — and the engine carry-over reset failed_messages, wiping its retry record. Now the incremental carry-over carries failed_messages forward (as independent copies), the within-channel skip gate excludes previously-failed ids for that channel so the messages phase re-POSTs them, and a completion-time reconciliation drops ids that succeeded this run and collapses the carried + fresh-re-fail duplicate to a single entry (so the failure count stays stable across runs). Running ferry retry before --incremental is no longer required — the v2.6.3 known-limitation note is resolved. --resume is unaffected (it does not consult failed_messages); an unchanged channel with no prior failures still makes zero POSTs.
  • A non-numeric carried offset no longer fails the channel worker (#77). The skip threshold used max(…, key=int) / int(_skip_below), but the periodic checkpoint persisted the raw msg.id, which can be non-numeric for some system messages; a prior run that crashed right after such a checkpoint then raised ValueError (swallowed by asyncio.gather → the channel was silently skipped). The threshold now filters/normalizes non-numeric values to "no threshold" (copy, never crash) for both --incremental and --resume, and the checkpoint write is guarded with isdigit().
  • The incremental per-channel completion event now distinguishes re-attempted failures from new messages ("{n} new, {m} already present, {k} retried"; the retried clause is omitted when zero, byte-identical to the prior message).

Notes

  • A permanently-unsendable message is re-attempted (one idempotent POST) on each incremental run, matching the existing ferry retry semantics (no retry cap); retry_count is preserved if a cap is ever wanted. Edited/deleted-message sync remains out of scope.

[2.6.3] - 2026-06-25

Fixed

  • Incremental mode now copies only NEW messages (migrator/messages.py, core/engine.py, state.py). From the 2026-06-24 v2.5.1 bug-hunt refresh — this closes the two items the v2.6.2 entry flagged as "tracked separately" (the incremental message-offset re-stream and the forum-index count double-count).
  • --incremental no longer re-streams and re-POSTs the entire history every run. The within-channel skip (messages.py) was gated solely on config.resume, but --incremental and --resume are mutually exclusive, so the skip never fired for incremental runs — every run did O(all-messages) work. Server-side idempotency (Idempotency-Key=ferry-{msg.id}) hid the duplicate POSTs, so the symptom was invisible: no speedup, ever.
  • Durable per-channel high-water mark (MigrationState.channel_high_water, persisted): the existing channel_message_offsets is transient within-run resume state and is popped on channel completion, so after a clean run there was nothing to skip from. The new field records the highest copied message id per channel, is written at channel completion (never popped), is carried into incremental runs by the engine, and is consulted by a mode-aware skip gate — resume keeps using the transient offset (unchanged), incremental uses max(high_water, carried_offset) so a crashed prior run degrades gracefully. Back-compat: an old state.json lacking the field loads with {} and the run falls back to a (harmless, idempotent) full re-copy.
  • The thread/forum header POST is now gated on the same marker, so an unchanged thread/forum channel makes zero POSTs on an incremental run (previously it re-POSTed [Thread migrated from #…] every run, idempotency-keyed only).
  • The forum-index message counter no longer inflates across incremental runs. It double-counted because re-processed messages re-incremented channel_message_counts; once messages stop re-processing the count holds — fixed for free, locked in by test.
  • Replaced two false-green delta tests (tests/test_delta_migration.py): a ... or True tautology and an assertion against the wrong (avatar) value. Added real-production-code guard tests (tests/test_messages.py) that drive the real messages phase against mocked Stoat endpoints and fail if the skip gate or the carry-over is removed.

Notes

  • Edited/deleted-message sync is explicitly out of scope — a high-water mark can only detect appended messages, not changes to already-copied ones.
  • Known limitation — run ferry retry before --incremental. A message that failed to POST on a prior run (recorded in failed_messages) is below the channel's high-water mark, so a later --incremental skips it and does not auto-retry it; failed_messages is also reset per incremental run, so its retry record is lost. Retry such failures with ferry retry against the prior state.json before running an incremental update. (Previously, the broken incremental re-streamed everything and thus re-attempted failures as a side effect; that accidental self-heal is gone now that re-streaming is fixed.) A proper fix — preserving failed-message retry across incremental runs — is tracked as a follow-up.

[2.6.2] - 2026-06-24

Fixed

  • Reporting integrity: post-migration reports were wrong on every run (reaction/pin counters + message-fidelity denominator). From the 2026-06-24 v2.5.1 bug-hunt refresh; affects all three reporting surfaces (ferry stats, migration_report.json, the markdown report).
  • Reaction fidelity is no longer stuck at 50% (migrator/reactions.py, reporter.py, stats.py): state.pending_reactions was never cleared, so every surface computed reactions_total = reactions_applied + len(pending_reactions) = 2N after a fully successful run → a flat 50% reaction sub-score. run_reactions now consumes pending_reactions as a work queue (applied entries removed, failed retained, cap-skipped removed), so the derived total self-corrects to N (100%).
  • Resume no longer double-counts (migrator/reactions.py, migrator/pins.py): the engine re-runs the in-progress phase on resume; with the pending list never consumed, reactions_applied/pins_applied re-incremented over the persisted value. The phases now checkpoint progress via save_state (periodic, mirroring the messages phase) so a resumed phase processes only the remainder. A new persisted MigrationState.reaction_message_counts keeps the 20-reactions-per-message cap exact across a resume.
  • ferry stats no longer reports 0% Messages fidelity on a normal run (stats.py, core/engine.py, state.py): summarize_state used the incremental-only prior_messages_total (default 0) as the message denominator. A new persisted MigrationState.source_messages_total (set by the engine from the post-filter export total, on every run incl. resume) is now the shared denominator for both ferry stats and the JSON/markdown report (legacy fallback to imported+failed / sum(exports)), so the two surfaces agree.
  • Dry-run now reports 100% for reactions/pins (clears the pending list) instead of 50%.

Notes

  • Server-side state was always correct — Stoat reaction/pin PUTs are idempotent; these were purely local counter/stats bugs. Out of scope (tracked separately): the incremental message-offset re-stream (migrator/messages.py) and the forum-index count double-count (core/engine.py).

[2.6.1] - 2026-06-24

Fixed

  • GUI token lifecycle — two HIGH security/state-lifecycle defects in the NiceGUI shell (gui.py), from the 2026-06-24 codebase bug hunt. Token cleanup is now centralised through a single _clear_tokens(storage, keys) helper and a _SESSION_TOKEN_KEYS = ("token", "discord_token") constant (stoat_url intentionally excluded — it is an instance URL, not a credential).
  • Orchestrated export no longer clears the still-needed Stoat token. _run_export's finally cleared both the Discord token and the Stoat token, but the orchestrated flow immediately redirects to /validate, whose guard (and /migrate's) requires storage["token"]. The synchronous finally runs before the queued redirect loads, so the 1-Click flow bounced the user back to setup with an empty Stoat field. The export finally now clears only discord_token; the Stoat token is cleared at the terminal migration screen.
  • Offline-mode migration now clears the Stoat token from on-disk storage. The migration _run had no finally, and offline mode (setup → /validate → /migrate, skipping /export) never reached the export cleanup — so the plaintext Stoat token persisted in .nicegui/storage-user.json after completion or error (a security.md violation). The migration _run gained a terminal finally that clears both tokens on success AND error, for both offline and orchestrated modes. Safe for the in-flight migration: FerryConfig holds its own token copy (built before the task), the engine never reads app.storage, and the only in-_run storage read precedes run_migration.
  • Guarded by a constant-pin unit test (tests/test_gui.py) that fails CI if the security-critical key list ever silently shrinks, plus helper behaviour tests.

Notes

  • Two non-terminal abandonment cases remain out of scope (tokens linger if the user opens /migrate or lands on /validate but never proceeds — fixing them needs client-disconnect hooks). Bounded by the single-user, local-only (127.0.0.1), gitignored-storage nature of the tool.

[2.6.0] - 2026-06-24

Added

  • Live role discovery — migrate every server role, not just roles whose members posted (migrator/structure.py, discord/metadata.py, discord/__init__.py, migrator/api.py). Previously run_roles derived its role set from msg.author.roles, so a role only migrated if someone wearing it posted in an exported channel — empty/structural/staff roles silently vanished. When a discord_token is supplied, role creation now sources from the union of export-derived roles and the full live Discord role list (already captured in discord_metadata.role_metadata, which enumerates every non-managed, non-@everyone role). For a role present in both, the live name/colour/position win over the (possibly stale) export snapshot. RoleMeta gained name and color (normalised to hex at capture); a _role_from_metadata adapter synthesises a DCERole for live-only roles so all three existing role passes (create / rank / permissions) cover them unchanged.
  • Graceful Stoat role-cap handling. Stoat enforces a configurable server_roles limit (default 200) that the wider role set can now reach. The live limit is read best-effort from GET / (features.limits.global.server_roles, via a new api_fetch_root wrapper, 200 fallback); if the union exceeds it, the lowest-priority roles by (position, id) are dropped with a single role_limit_exceeded warning, and a TooManyRoles create-time backstop stops gracefully rather than crashing the phase.
  • Reporter credit (reporter.py): the ## Native Fidelity section now reports the count of recovered "structural" roles (live-only roles absent from the export), counted per-run.

Changed

  • Role rank ordering now sorts by (position, id) (string id) for a deterministic tie-break when multiple roles share a Discord position — relevant now that the full live role list (with denser positions) feeds the rank pass.

Notes

  • Without a discord_token (no live metadata), role sourcing falls back to the prior export-derived behaviour byte-for-byte — same role set, same create order. Managed/integration roles and @everyone remain excluded. This does not assign members to roles (that requires a Discord→Stoat identity map, which does not exist); it only ensures the roles themselves are created.

[2.5.1] - 2026-06-24

Fixed

  • Incremental (--incremental) migration now reuses the prior server and structure instead of duplicating everything. Fixes the dominant cluster from the 2026-06-23 codebase bug hunt: the structure phases had no idempotency guard, so every delta re-run re-created all roles/categories/channels and run_server minted a brand-new server (stranding the carried ID maps).
  • Server reuse (migrator/structure.py): run_server reuses the carried state.stoat_server_id (not only --server-id); a deleted prior server raises a clear MigrationError instead of silently creating a new server.
  • Structure-phase idempotency (run_roles/run_categories/run_channels): a capture-before-mutate pre_existing_*_ids snapshot skips already-migrated roles (across the create, attributes, and permissions passes), reuses carried category/channel IDs, preserves category membership (no orphaning), reuses forum-index channels, and excludes carried channels from the --max-channels truncation budget.
  • Exact category membership (state.py, core/engine.py): a persisted channel_categories map reconstructs carried-only categories exactly on a partial re-export (no cross-contamination).
  • Carry-over completeness (core/engine.py): the incremental carry-over now also carries forum_channel_members, forum_category_names, forum_index_message_ids, channel_message_counts, category_names, channel_categories, and native_fidelity_counts, with a field-by-field audit comment. _rebuild_forum_indexes PATCHes the existing pinned index with cumulative counts instead of posting duplicates.
  • Locked by tests/test_incremental_structure.py (real-phase two-run tests). The prior tests/test_delta_migration.py mocked the structure phases, which is why this shipped undetected.

[2.5.0] - 2026-06-24

Added

  • Native-fidelity batch 2: per-channel slowmode, voice user-limit, and image role icons. Successor to the v2.3.0 batch (#62). Three more native Discord properties now migrate when a discord_token is supplied, all source-verified against stoatchat/stoatchat @ a15a542:
  • Per-channel slowmode (migrator/structure.py, migrator/api.py). Discord rate_limit_per_user is captured into ChannelMeta.slowmode and applied via a new api_edit_channel PATCH wrapper (routes through the existing rate-limited _api_request). Applied only when > 0, clamped to Stoat's 0..=21600 range with a slowmode_clamped warning. Slowmode is a flat top-level PATCH field.
  • Voice channel user-limit (migrator/structure.py). Discord voice user_limit is captured into ChannelMeta.user_limit and applied as voice={"max_users": N} (nested). Discord's 0 (unlimited) maps to an omitted max_users (Stoat rejects 0). The voice PATCH is gated by a created_as_voice flag so it never fires on a channel that fell back to Text via the Bug #194 retry; slowmode still applies to such fallbacks.
  • Image role icons (migrator/structure.py, discord/client.py). A role's image icon is downloaded from the Discord CDN (download_role_icon, capped at the Autumn 2.5 MB icons limit), uploaded to Autumn under the icons tag, and folded into the existing role attributes pass as DataEditRole.icon. Unicode-emoji-only role icons are not migratable and are skipped with a warning. The Autumn upload failure is caught as AutumnUploadError and reported with a fixed, role-name-only message — never the raw exception body, which can echo the session token (security.md).
  • Reporter credit + shell surfacing (reporter.py, cli.py, gui.py, state.py). Applied counts accumulate in MigrationState.native_fidelity_counts (serialized for resume) and surface as report["native_fidelity"], a ## Native Fidelity markdown section, a CLI summary line, and a GUI completion-card label (hidden when empty).
  • Graceful degradation. With no discord_token, all three fields skip and emit distinct one-time slowmode_skipped / user_limit_skipped / role_icon_skipped warnings; migration completes and v2.3.0 fields are unaffected.

[2.4.2] - 2026-06-23

Fixed

  • Resume no longer crashes after a validated migration (core/engine.py). When validate_after is enabled, run_migration persists current_phase="validate_migration" — a terminal phase that is not in PHASE_ORDER. A subsequent --resume called PHASE_ORDER.index(state.current_phase) in _run_phases with no membership check, raising ValueError: 'validate_migration' is not in list before any phase ran, aborting the resume entirely. The resume guard now treats any current_phase outside PHASE_ORDER as "all runnable phases complete" (current_idx = len(PHASE_ORDER)), so resume correctly skips the finished pipeline instead of crashing. Locked by test_run_migration_resume_after_validate_does_not_crash.
  • GUI progress bar no longer crashes on the post-migration validation event (gui.py). The migration progress handler called PHASE_ORDER.index(event.phase) on every non-report completed event, but the engine emits a phase="validate_migration" completed event when post-migration validation passes — a value not in PHASE_ORDER, raising ValueError inside the event handler. Extracted a _phase_progress(phase) helper that returns None for post-pipeline phases; the handler skips the progress-bar update for them. Locked by test_phase_progress_pipeline_phases and test_phase_progress_post_pipeline_phase_is_none.

Security

  • dce_checksums.json is now bundled into the frozen binary (ferry.spec). The PyInstaller spec bundled only templates/*.json, so the shipped binary lacked the pinned DCE hashes. exporter/manager._verify_dce_checksum reads them via importlib.resources, and its except (FileNotFoundError, ModuleNotFoundError): return clause then silently skipped checksum verification — defeating the supply-chain hard-fail gate from v2.2.12, but only in the packaged app (source-run tests always had the file, so the suite never caught it). Added ("src/discord_ferry/dce_checksums.json", "discord_ferry") to all_datas. Locked by test_ferry_spec_bundles_dce_checksums.

[2.4.1] - 2026-06-23

Security

  • Cleared all 14 open Dependabot alerts via three lockfile bumps (no application code affected; all alerts were in uv.lock):
  • aiohttp 3.14.0 → 3.14.1 (8 alerts). Ferry uses aiohttp as an HTTP client, so the server-side advisories (HTTP-parser max_line_size bypass, pipelined-request queue, websocket frame limits, client_max_size) do not apply; the client-side ones (DigestAuth cross-origin redirect credential leak GHSA-hpj7-wq8m-9hgp, cookie-jar domain promotion GHSA-2fqr-mr3j-6wp8, TLS hostname-override on connection reuse GHSA-4m7w-qmgq-4wj5) are patched. The aioresponses stream_writer shim in conftest.py is version-conditional and unaffected.
  • starlette 1.2.1 → 1.3.1 (2 alerts, incl. HIGH GHSA-82w8-qh3p-5jfq — request.form() size limits silently ignored). Transitive via NiceGUI→FastAPI; powers the local-only GUI web server.
  • python-multipart 0.0.28 → 0.0.32 (4 alerts, incl. HIGH GHSA-5rvq-cxj2-64vf — quadratic-time querystring parsing). Transitive via NiceGUI. Full suite green on the upgraded resolve.

[2.4.0] - 2026-06-23

Added

  • probe CLI subcommand — live-instance diagnostics. discord-ferry probe --test-server-id <id> runs four read-mostly checks against a live Stoat instance and renders a Rich table (or --json): Autumn per-tag size limits vs. our assumptions, voice Bug #194 detection (this Revolt fork has no VoiceChannel variant — judged on the presence of a voice field, not the discriminator), webhook availability (judged on EXECUTE, which is mounted only when features.webhooks_enabled is true — default off), and rate-limit header capture. Every entity created under the throwaway test server is torn down in a finally block (capture-id-before-raise), and the probe never constructs or writes a MigrationState. New module migrator/probe.py (run_probe, ProbeReport).
  • Post-migration invite generation (S4). The migration now mints an invite to the new Stoat server during the REPORT phase and surfaces it in the CLI summary, migration_report.json (invite block), the markdown report (## Invite), and the post-migration checklist. Controlled by --create-invite/--no-create-invite (default on) and --invite-channel-id. _select_invite_channel picks a Text channel, excluding voice (Discord type 2), threads, and synthetic forum-index channels; forums (15/16 → Stoat Text) are eligible. The invite URL is built best-effort from the instance root's app field (bare code otherwise). Non-fatal on error (migration is already complete → warning, never raises), idempotent via an invite_code guard in both the engine caller and _generate_invite, and carried forward on incremental re-runs so resumes never re-mint. New MigrationState.invite_code/invite_url (serialised, forward-compatible). Failure warnings are sanitised through the token store before reaching state.json.

Internal

  • Four probe-support API wrappers + _headers(token: str | None) widening (migrator/api.py): api_create_invite, api_create_webhook, api_fetch_channel, api_delete_webhook, and an auth-leak-safe api_execute_webhook that passes token=None so the user's x-session-token is never sent to the URL-token-authenticated webhook-execute endpoint. Webhook wrappers are probe-only — webhook-based message posting is deliberately out of scope.

Fixed

  • icons Autumn size limit corrected to 2_500_000 (uploader/autumn.py). Source check against stoatchat Revolt.toml (features.limits.default.file_upload_size_limit) shows a flat 2.5 MB, not the 2560 * 1024 = 2_621_440 we previously enforced — an icon between those sizes would pass our pre-check then be rejected by Autumn.

[2.3.0] - 2026-06-23

Added

  • Role hoisting is now migrated (S1). Discord's per-role hoist flag (which displays a role as a separate member-list group) was previously dropped — every migrated role collapsed into the default list. fetch_and_translate_guild_metadata (discord/__init__.py) now captures hoist (and position) into a new RoleMeta carrier on DiscordMetadata, and run_roles (migrator/structure.py) applies it. The former rank-only second pass became an attributes pass that folds rank and hoist into a single api_edit_role call per role, so hoist is applied even for roles with no colour and position 0 — and with no extra API calls versus before. mentionable is intentionally not migrated (no Stoat equivalent). Requires discord_token; without it, hoist is skipped with one warning (hoist_skipped). Locked by test_run_roles_applies_hoist_when_metadata_present and test_run_roles_hoist_skipped_without_metadata.
  • Server description and NSFW flag are now migrated (S2). Both are native Stoat fields that were never read from the source guild. fetch_and_translate_guild_metadata now captures guild_description and guild_nsfw, and run_server applies them via the existing api_edit_server PATCH (description sent only when non-empty). Skipped with one warning (server_meta_skipped) when Discord metadata is unavailable. Locked by test_run_server_applies_description_and_nsfw, test_run_server_omits_empty_description, and test_run_server_meta_skipped_without_metadata.
  • Categories are now ordered by their Discord position (S3). Categories previously rendered in DCE export-iteration order (effectively arbitrary). The authoritative second api_upsert_categories call in run_channels now sorts the categories array by each category's captured Discord position (new DiscordMetadata.category_positions, populated only from type-4 GUILD_CATEGORY channels). Forum-derived categories — keyed in category_map by a non-Discord forum key and thus without a position — sort to the end with a stable title tie-break. Falls back to the prior order when metadata is absent. Locked by test_run_channels_orders_categories_by_discord_position and test_run_channels_category_without_position_sorts_last.

Changed

  • DiscordChannel now parses the channel position field (discord/models.py, discord/client.py), previously discarded — needed for category ordering above.

Internal

  • Extracted _stoat_channel_type(int) -> str in migrator/structure.py as the single source of truth for the Discord-type → Stoat-type mapping (type 2 → Voice, else Text), replacing the inline match in run_channels.
  • All three new metadata fields round-trip through discord_metadata.json with .get()-defaulted decodes, so pre-upgrade metadata files load forward-compatibly and trigger the graceful-skip path. Locked by a deep-equality round-trip test in tests/test_metadata.py.
  • The end-of-run server invite (S4) originally scoped alongside these features was dropped from this release: api_create_invite belongs to the in-progress probe-and-invites feature, so invite generation will ship there rather than be duplicated here.

[2.2.12] - 2026-06-07

Security

  • DCE checksum verification now hard-fails on an unpinned platform (closes #37, phase 2). _verify_dce_checksum (exporter/manager.py) previously returned silently when no SHA-256 hash was pinned for the requested version/platform — the exact silent-skip behavior that left osx-arm64 and linux-arm64 downloading unverified DCE binaries for years (phase 1, v2.1.5, pinned the missing ARM hashes; this phase closes the hole structurally). It now raises DCENotFoundError naming the platform, refusing to use an unverified binary, and pointing at the escape hatches (--skip-dce-verify CLI / skip_verify=True API) plus a request to file a bug to add the hash. Behavior change (latent breaking): if a future contributor adds a platform to _PLATFORM_MAP without pinning its hash, Ferry now errors on that platform until the hash is added — instead of silently shipping an unverified binary. All five currently-supported platforms (win-x64, linux-x64, osx-x64, osx-arm64, linux-arm64) are pinned, so no supported user is affected; the existing test_dce_checksums_json_covers_all_supported_platforms regression guard keeps it that way. The missing-checksums-file path remains a skip (the file is bundled via importlib.resources; its absence is a packaging edge, not a platform-coverage gap). Locked by three tests in tests/test_exporter_manager.py: test_dce_checksum_empty_hash_raises, test_dce_checksum_missing_version_raises, and test_dce_checksum_unpinned_platform_raises (synthetic win-arm64). Deferred since v2.1.5 (PR #42) for soak time, now satisfied.

[2.2.11] - 2026-06-07

Security

  • Upgraded aiohttp 3.13.5 → 3.14.0 (closes Dependabot #22, #23). Two medium-severity advisories affect every aiohttp < 3.14.0: GHSA-jg22-mg44-37j8 (deserialization of untrusted data) and GHSA-hg6j-4rv6-33pg (cross-origin redirect leaks per-request cookies). Ferry ships aiohttp inside the PyInstaller binary and uses it for all Stoat/Autumn/Discord HTTP, so the patched runtime ships to users. The Dependabot bump (PR #58) was correct but could not merge on its own — see the test-harness fix below.
  • Upgraded starlette 1.0.0 → 1.2.1 (closes Dependabot #24). GHSA-86qp-5c8j-p5mr (medium): starlette ≤ 1.0.0 is missing Host-header validation, which poisons request.url.path and can bypass path-based security checks. starlette is a transitive dependency (via nicegui and fastapi); both declare it without a version constraint, so the upgrade resolved cleanly to the latest 1.2.1 — past the 1.0.1 patch floor — with no other package churn in uv.lock. Full suite passes on the upgraded resolve.

Bug Fixes

  • Test harness: aiohttp 3.14 / aioresponses 0.7.8 compatibility shim (tests/conftest.py). aiohttp 3.14 made stream_writer a required keyword-only argument of ClientResponse.__init__. aioresponses 0.7.8 builds its mock responses by calling that constructor directly without it, so under 3.14 every mocked HTTP call raised TypeError: ClientResponse.__init__() missing 1 required keyword-only argument: 'stream_writer' at aioresponses/core.py:172 — collapsing the suite from 925 green to a mass failure (test_api, test_autumn, test_connect, test_discord_client, provisioning/* all red). This was purely a mock-construction incompatibility; Ferry's own runtime use of aiohttp 3.14 is unaffected. The upstream fix (aioresponses#288) is unmerged/unreleased (latest release is 0.7.8), so conftest.py carries an equivalent: a setdefault("stream_writer", Mock(output_size=0)) wrapper around ClientResponse.__init__, guarded by an inspect.signature check so it is a no-op on aiohttp < 3.14 and inert once aioresponses ships the fix. A REMOVE THIS marker ties the shim to the upstream PR for clean removal. Full suite restored to 925 passed.

[2.2.10] - 2026-05-19

Security

  • state.errors.append callsites now sanitize messages through the token store (closes #47). v2.0.1 wired SecureTokenStore and added _safe_error / sanitize_for_display helpers, but a coverage audit during v2.2.0's ship review found five state.errors.append callsites that interpolated raw exception repr()s without going through any sanitizer: migrator/emoji.py:296, migrator/reactions.py:112, migrator/pins.py:83, migrator/messages.py:382, and core/engine.py:599. If a Stoat/Discord/Autumn aiohttp exception's repr() ever contained a token value (token-in-URL leak from a misconfigured HTTP library, 401 body echo, etc.) it landed in state.json unredacted — visible via cat state.json, ferry stats's last_error 80-char preview (added in v2.2.3), and reporter.generate_markdown_report's ### Errors section. The exposure pre-dated ferry stats; that command surfaced it more discoverably without creating new ingress. Project rule .claude/rules/security.md ("Never log tokens... in log output, error messages, or state files") was violated by construction at all five sites.
  • Fix: promoted _safe_error from migrator/messages.py (private, used at one site) to core/security.py:safe_sanitize(token_store, text) — a None-tolerant wrapper around SecureTokenStore.sanitize so test paths without a registered store don't have to thread a fake one through. All five callsites now wrap their "message" (or "error") field through safe_sanitize(config.token_store, ...). The core/engine.py site is the trickiest: the same str(e) interpolation also flowed into the wrapped MigrationError, the MigrationEvent.message, and the event detail's error — all four are now sanitized via a single safe_exc local, since partial sanitization would still leak via the event log panel or the propagated exception.
  • Locked by three regression tests in tests/test_security.py: test_safe_sanitize_returns_text_unchanged_when_store_is_none (no-op without store, so test paths keep working), test_safe_sanitize_masks_registered_tokens (multi-token replacement), and test_safe_sanitize_at_state_errors_call_site (end-to-end: synthetic token in a RuntimeError's URL → masked in the resulting state.errors[-1]["message"]).
  • Acceptance: grep -n "state.errors.append" src/ shows all five sites; manual audit confirms each goes through safe_sanitize (4 migrator sites directly in the "message" value, 1 engine site via the shared safe_exc local). Note: v2.2.3's CHANGELOG cross-referenced this fix as "#46" — the actual issue number is #47; #46 was the unrelated reporter fidelity bug fixed in v2.2.9.

[2.2.9] - 2026-05-19

Bug Fixes

  • reporter fidelity score: reactions sub-score no longer inflates in partial state (closes #46). generate_report (reporter.py:120) and generate_markdown_report (reporter.py:351) both passed reactions_total=len(state.pending_reactions) to compute_fidelity_score — a denominator that's wrong in both terminal states. Completed migration: pending_reactions is drained → denominator is 0 → the function's if reactions_total else 1.0 short-circuit silently returns 100%, hiding any reactions that failed. Partial migration (e.g. reactions_applied=10, pending_reactions=[5 items]): denominator is 5 → ratio is 10/5 = 200%, inflating the overall score by up to 10 percentage points (the reactions weight). Both callsites now use the formula state.reactions_applied + len(state.pending_reactions), matching what stats.summarize_state (added in v2.2.3) already does — so ferry stats and migration_report.json/.md agree on the reactions sub-score for the same state. Locked by test_fidelity_reactions_partial_state_not_inflated (asserts 60-70% for the 10/15 case) and test_fidelity_reactions_completed_state_is_100_percent (asserts the completed case now reaches 100% via the corrected formula, not the zero-denominator short-circuit). Note: v2.2.3's CHANGELOG cross-referenced this fix as "#47" — the actual issue number is #46; #47 tracks the unrelated state.errors sanitization gap.

[2.2.8] - 2026-05-18

Bug Fixes

  • diff() thread-keying disambiguates threads from forum posts (tests/provisioning/_applier.py). Both Discord threads and forum posts use channel type 11, so the previous actual_threads = {ch.name: ch for ch in actual.channels if ch.type == 11} would silently collide if a manifest grew to include a thread and a forum post with the same name — only one would survive in the dict and the other manifest entity would be spuriously reported as missing/needing creation. Re-keyed to (parent_id, name) tuples and threaded a manifest_tc_id_to_discord_id map through the diff so the lookup targets the correct text-channel parent. Current fixture (Cool Thread vs Bug Report) doesn't trigger the collision; the new test_diff_distinguishes_thread_from_forum_post_with_same_name regression test injects a colliding foreign forum post named "Cool Thread" inside feedback-forum and asserts the manifest's Cool Thread thread under general is still matched without drift.

Docs

  • tests/provisioning/README.md "no CI" rule scoped explicitly. The previous wording ("this script must NEVER run in CI") was ambiguous about whether the rule covered provision_test_server.py (yes — that's the human-only CLI) or tests/provisioning/test_*.py (no — those are hermetic via aioresponses and run alongside the rest of the suite in CI). Clarified that the rule applies to the CLI only.

[2.2.7] - 2026-05-18

Bug Fixes

  • Null-field collapse extended across the remaining parser call sites. v2.2.6 (PR #51) fixed the str(raw.get(K, default)) → "None" pattern in _parse_channel and _parse_message's reference block — the four fields exposed by the new captured fixtures. The same pattern remained in seven other parsing sites (_parse_export exportedAt, _parse_guild iconUrl, _parse_author discriminator/nickname/avatarUrl, the inline DCEEmoji construction in _parse_reaction for id/name/imageUrl). Each of those produced the truthy 4-char string "None" when DCE serialized a present-but-null value — Pomelo-era users with no discriminator/nickname/avatar, guilds with no icon, and unicode emojis with no ID or image URL. Fixed by switching every site to the str(raw.get(K) or default) pattern, preserving the existing defaults ("" everywhere except discriminator's "0000"). Locked by three new unit tests against the private parse functions: test_parse_guild_null_icon_url_collapses, test_parse_author_null_fields_collapse_to_defaults, test_parse_reaction_unicode_emoji_null_id (the last verifies the unicode-emoji case end-to-end since unicode emojis natively emit null for both id and imageUrl).

[2.2.6] - 2026-05-18

Tests

  • Real DCE 2.47.1 fixtures replace synthetic ones (closes issue #35). Three real DiscordChatExporter exportguild captures from a Discord guild provisioned via tests/provisioning/provision_test_server.py now back the parser test contract: Discord Ferry Test - general [1506019498094891120].json (10 user messages + 1 ThreadCreated, including the 3-inline + 2-non-inline embed), Discord Ferry Test - general - Cool Thread [1506019505778987190].json (thread starter + reply), and Discord Ferry Test - feedback-forum - Bug Report [1506019530294562938].json (forum post body). The synthetic Test Server - general - Cool Thread [888888888888888888].json and Test Server - Feedback Forum - Bug Report [999999999999999999].json are removed. test_parser.py updated for the new filenames, the parse_export_directory count assertion (5 → 6 valid DCE JSONs), and the Discord-normalized feedback-forum parent name in test_forum_export_detected.

Bug Fixes

  • Parser accepts DCE 2.47.1's enum-named channel types. DCE 2.47.1 emits "type": "GuildTextChat" and "type": "GuildPublicThread" where pre-2.47 releases emitted integers (0 and 11). The parser previously called int(raw["type"]) directly, which ValueErrors on the new format — discovered when the real DCE captures used to replace tests/fixtures/ synthetics failed to parse. Fixed with a _DCE_CHANNEL_TYPE_TO_INT mapping covering the 11 channel enum names emitted by DCE's ChannelKind and a _coerce_channel_type helper that accepts both string and integer inputs; downstream callers (migrator/structure.py, migrator/messages.py, review.py) continue to branch on Discord-canonical integer codes unchanged. Locked by five new unit tests covering int passthrough, known-string mapping, digit-only strings, None input, and unknown-string ValueError.
  • Null JSON fields collapse to "" instead of the truthy string "None". DCE emits null for categoryId/category on top-level channels and for topic on threads — and for reference.messageId on ThreadCreated system messages. The previous str(raw.get(K, "")) pattern returned "None" for these (because dict.get only uses the default when the key is missing, not when it's present with a null value). The four-character truthy string "None" then slipped past downstream truthy guards in migrator/structure.py:496 (if cat_id and ...), review.py:73 (if export.channel.category_id: ...), and cli.py/gui.py channel-scan summaries — potentially leading to phantom-category creation. The synthetic fixtures hid the bug by using "" empty strings; the real captures surfaced it. Fixed with str(raw.get(K) or "") in _parse_channel (3 fields) and _parse_message's reference block (3 fields). Locked by test_null_json_fields_collapse_to_empty_string against the new captured fixtures.

[2.2.5] - 2026-05-18

Tooling

  • tests/provisioning/ test-server provisioning CLI shipped (issue #35 enabler). Standalone Click-based tool with provision / teardown / verify subcommands that hits Discord's REST API using a Bot token from DISCORD_TEST_BOT_TOKEN. Human-run only; the import firewall ([tool.hatch.build.targets.wheel] packages = ["src/discord_ferry"]) ensures the bot-auth write paths never ship in installable wheels. Architecture: three-layer (transport _bot_api.py → logic _applier.py → CLI provision_test_server.py). Reconciler uses a sealed DiffOpT discriminated union with assert_never exhaustiveness; per-op priority ordering ensures channels are created before messages/threads/posts depend on them. Verified end-to-end against a live Discord guild — the full six-step smoke test (dry-run → provision → verify-match → idempotent re-provision → teardown → verify-drift) passes cleanly. 61 hermetic unit tests run alongside the rest of the suite (mocked via aioresponses).

Bug Fixes

  • diff() channel-name normalization (caught by the live smoke test, not the mock-based unit tests). Discord lowercases and hyphenates channel names server-side, so a manifest entry "Feedback Forum" is stored as "feedback-forum". The diff comparator previously matched by exact name and falsely reported the forum channel as missing. Fixed with _normalize_channel_name (lowercase + spaces→hyphens + strip non-[a-z0-9_-]) applied symmetrically on both sides of the lookup. Locked in by test_diff_matches_discord_normalized_channel_names.
  • 403 error message no longer leaks guild IDs. The previous url.split('/')[-2] returned the guild snowflake for nested endpoints like /api/v10/guilds/{id}/channels, exposing a guild ID in the exception text. Now uses url.removeprefix(DISCORD_API_BASE) to keep only the relative path.
  • ProvisioningPermissionError exits 2 across all CLI subcommands. Previously a 403 fell through to the bare ProvisioningError handler and exited 1 (drift) instead of 2 (couldn't-determine), violating the documented 3-way exit-code contract in tests/provisioning/README.md. Permission errors are now handled explicitly alongside auth errors.
  • reconcile_teardown no longer swallows auth/permission failures into skipped_count. Previously a token revocation mid-teardown reported "deleted 0, skipped N" with exit 0, masking the failure. The function now re-raises ProvisioningAuthError and ProvisioningPermissionError so the CLI can surface them as exit 2. Other ProvisioningErrors (5xx, network) are still treated as transient and counted in skipped_count. The CLI now also prints a warning when skipped_count > 0 so operators have a clear retry hint.

[2.2.4] - 2026-05-17

Internal

  • Foundation for tests/provisioning/ dev tool (issue #35 enabler). Establishes the architectural firewall and bootstraps the package for the upcoming Discord test-server provisioning CLI. Three pieces shipped: (a) [tool.hatch.build.targets.wheel] packages = ["src/discord_ferry"] stanza in pyproject.toml ensures tests/provisioning/ is excluded from built wheels (verified: 0 entries under tests/ in the built .whl); (b) PEP 561 src/discord_ferry/py.typed marker so downstream mypy tests/ can honor the package's types instead of treating it as untyped; (c) tests/provisioning/__init__.py + _bot_api.py exposing a ProvisioningError hierarchy that deliberately does NOT inherit from FerryError — the firewall is enforced structurally (location → wheel exclusion) and reinforced in types (no shared exception root). The full provisioning tool (provision/teardown/verify subcommands, ~1,400 LOC across ~20 commits) is planned for a follow-up session.

Changed

  • Python floor formalized at 3.11. requires-python bumped from >=3.10 to >=3.11, the 3.10 trove classifier removed, ruff target-version bumped to py311, and mypy python_version bumped to 3.11. CLAUDE.md has declared Python 3.11+ as the floor since the project's first internal commit; this PR aligns the externally-visible metadata. Any consumer pinning to Python 3.10 should remain on 2.2.3 or upgrade their interpreter. The ruff bump surfaced 12 modernization opportunities in src/, all auto-applied: datetime.now(timezone.utc) → datetime.now(UTC) (PEP 615 alias added in 3.11), and except asyncio.TimeoutError → except TimeoutError (3.11 stdlib alias). Behavioural equivalence preserved.

[2.2.3] - 2026-05-16

Features

  • ferry stats <output-dir> CLI subcommand: post-migration introspection that reads state.json + message_map.json from a completed (or in-progress) migration and prints a Rich table to the console — entity counts (channels/roles/categories/emojis/messages), message counters (attachments/pins/reactions/replies/embeds/failed/prior), fidelity score (overall + 5 sub-scores via reporter.compute_fidelity_score), error/warning summary with truncated last-message preview, and elapsed duration. Optional sub-sections render when state contains them: per-channel message breakdown (top 20 by count + "+N more"), rollback counters (when state.rollback_progress is set), and a [DRY-RUN] badge in the title (when state.is_dry_run is true). Pure state-only introspection — no Stoat API, no Autumn, no Discord API, no DCE re-parse. Failure modes are clean: missing state.json or corrupt JSON exits 1 with a single human-readable error line (no traceback).
  • Zero-denominator fidelity handling: when an empty category (e.g. zero embeds in the migration) would otherwise short-circuit compute_fidelity_score to 100%, the sub-score renders as n/a instead. The overall score still uses the function's arithmetic unchanged ("no embeds to migrate = no embed loss" is a defensible interpretation).
  • reactions_total derivation: MigrationState lacks a reactions_total field, so summarize_state derives it as reactions_applied + len(pending_reactions) — yielding correct ratios in both completed and partial states. Note: reporter.generate_report uses the existing len(pending_reactions) formula, which overestimates in partial state; the two surfaces will report different reaction sub-scores until reporter is aligned in a follow-up (tracked in #47).

Internal

  • reporter._calculate_duration → reporter.calculate_duration: promoted from private to public (with docstring) so stats.py and reporter.py share one implementation of the ISO-8601 elapsed-seconds helper. No behavioural change; pure rename + docstring + return-literal-tightening (0 → 0.0) for mypy strict mode.

Known Issues

  • Unsanitized error messages in state.errors (pre-existing, tracked in #46): audit during 2.2.0 ship-review found that state.errors.append callsites in migrator/emoji.py:296, migrator/reactions.py:112, migrator/pins.py:83, migrator/messages.py:382, and core/engine.py:599 do not wrap their messages with _safe_error / config.token_store.sanitize — meaning a Stoat/Discord/Autumn exception whose repr() happens to contain a token (e.g. token-in-URL leak from a misconfigured HTTP library) would land in state.json unredacted. This pre-existing exposure also affects reporter.generate_markdown_report (which renders state.errors verbatim) and on-disk inspection of state.json itself. The new ferry stats command surfaces these errors via a truncated 80-char preview, making the exposure more discoverable but not creating new ingress. Follow-up: #46 will wrap all state.errors.append callsites in the migrator with _safe_error. The fix belongs in the engine, not in stats consumers — stats was deliberately designed without FerryConfig/token_store access. Until then, treat ferry-output/state.json as sensitive (it always has been).

[2.2.2] - 2026-05-16

Added

  • Adaptive heartbeat during prolonged DCE silence (#39). Long Discord channel-enumeration phases (5-15 min on large guilds) previously left the GUI silent because Spectre.Console's status ticker is suppressed when stdout is piped. The runner now spawns a _heartbeat task alongside the existing stdout/stderr drain that emits status="heartbeat" MigrationEvents at adaptive intervals (60s, 120s, 240s, then capped at 300s). Any "real activity" (per-channel progress, phase headlines, success, or any non-banner/non-status-dot line) resets the interval to 60s. Heartbeats appear in the GUI log panel only — the progress bar and channel label are untouched. MigrationEvent.status now recognizes "heartbeat" as a value; existing consumers that don't recognize it degrade gracefully (the GUI's on_export_event already pushes every event to the log regardless of status).

[2.2.1] - 2026-05-16

Added

  • DCE contract test in CI (#34). New tests/test_dce_contract.py invokes the real DCE 2.47.1 --help and asserts every flag _build_dce_command() passes is still present in DCE's output; new tests/test_dce_output_replay.py replays the captured fixture through parse_dce_line and asserts the typed result for each non-comment line. Both run in a dedicated contract-test CI job on ubuntu-latest (Python 3.12, .NET 8 preinstalled on ubuntu-24.04), with the DCE binary cached across runs by DCE_VERSION. When DCE_VERSION bumps in future, the cache key changes and a fresh DCE is downloaded + re-asserted against _build_dce_command() — drift in either direction now fails CI on the bump PR instead of silently shipping.

[2.2.0] - 2026-05-16

Changed

  • MigrationEvent.current / MigrationEvent.total semantics changed for export-phase events (issue #23). Previously these fields held the per-channel percentage (current=NN, total=100). They now hold the overall channel count (current=channels_done, total=total_channels). The progress-bar fraction current / total is unchanged in shape (still 0-1) but its meaning is "fraction of channels completed" instead of "fraction of current channel completed." Any external consumer of MigrationEvent reading these fields needs to know.

Fixed

  • DCE export progress was invisible -- bug latent since 2026-02-28 (issue #23): the per-channel progress regex never matched any line that DCE 2.47.1 actually emits. Verified by reading Tyrrrz/DiscordChatExporter@2.47.1 source -- DCE uses Spectre.Console's FallbackProgressRenderer when stdout is piped, which emits <channel name>: NN% (no brackets, no #, hierarchical names joined with /). User-visible symptom: GUI sat on "Discord Chat Exporter -- Started" for 5-30 minutes on large servers. Replaced with a typed-union parser (src/discord_ferry/exporter/dce_output.py) handling all DCE 2.47.1 line types: PerChannel, Phase (Fetching/Fetched/Exporting headlines), Success, Banner, StatusDot, and Raw fallthrough for unknown lines. Unknown lines now surface to the GUI as [dce] <line> instead of being lost to logger.debug. Reported by @The-Red-Priest.
  • Windows console flash during DCE startup: DCE child process was spawned without CREATE_NO_WINDOW, briefly flashing a console window before the parent claimed the pipes. Now spawned with CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP on Windows.
  • Stderr drain task leaked on cancel path: cancelling an export mid-stream left the _read_stderr task running until garbage collection. Now cleaned up via try/finally on every exit path.
  • asyncio.LimitOverrunError would crash the GUI on long lines: a single DCE stdout line exceeding 64 KiB (improbable, but possible from malformed JSON paths in error traces) would bubble out of the stdout loop, bypassing cancel checks and crashing the event loop. Now caught and reported as [dce] <truncated N bytes; line exceeded 64 KiB> while the loop continues.
  • Windows cancel left partial JSON files: hard-kill on Windows gave DCE no chance to flush. Now sends CTRL_BREAK_EVENT to the DCE process group (DCE has a CancelKeyPress handler), with a 3-second graceful-shutdown window before falling back to hard kill. POSIX behavior unchanged (already used SIGTERM). Caveat: the channel currently being exported may still produce a partial JSON file -- users should expect to delete the export folder before retrying after a cancel.

[2.1.6] - 2026-05-16

Fixed

  • Open Report button now works on Windows (#38). Previously, the GUI's "Open report" action tried to invoke xdg-open on Windows (sys.platform == "win32" falls into the non-darwin branch), which doesn't exist — the click silently failed with a ui.notify toast. Replaced with a three-branch platform check using os.startfile(path) on Windows. Bundled audit confirmed xdg-open was the only Windows-incompat bug in src/ (full grep results in docs/superpowers/specs/2026-05-15-issue-38-windows-compat-audit-design.md); no other code paths needed changes.

[2.1.5] - 2026-05-16

Fixed

  • ARM platforms (osx-arm64, linux-arm64) now have SHA-256 hash verification on DCE download (#37, phase 1 of 2). Previously, _verify_dce_checksum (src/discord_ferry/exporter/manager.py:75-77) silently skipped verification for any platform without a pinned hash — Apple Silicon (default Mac since 2020) was downloading DCE binaries with no integrity check. Now pinned for DCE 2.47.1: osx-arm64, linux-arm64. The silent-skip behavior is preserved as a fallback for any future unrecognized platform (phase 2, shipping as v2.2.0, will flip silent-skip to a loud DCENotFoundError). New schema-validation tests in tests/test_exporter_manager.py::TestDceChecksumsJson guard against future regressions: any new platform added to _PLATFORM_MAP without a corresponding hash now fails CI.

[2.1.4] - 2026-05-16

Fixed

  • Embed fields with isInline: true now correctly render as pipe-separated rows in migrated messages (#36). The parser previously read the wrong key (inline), causing every multi-field Discord embed to render as a stacked list since v1. Verified against DCE 2.47.1 source (JsonMessageWriter.cs:259 writes isInline). Re-running migration on a server with bot-posted embeds will produce visibly cleaner output. The accompanying parser-audit appendix (see spec docs/superpowers/specs/2026-05-15-issue-36-isinline-and-parser-audit-design.md) confirmed this was the only key-typo in transforms.py and dce_parser.py.

[2.1.3] - 2026-05-14

Fixed

  • GITHUB_TOKEN cascade gap closed (Option C): auto-tag.yml now uses a GitHub App installation token (minted via actions/create-github-app-token@v3) instead of the default GITHUB_TOKEN for tag pushes. The default token by GitHub safety does NOT trigger downstream workflows — three sequential releases (v2.1.0, v2.1.1, v2.1.2) all required a manual git push --delete origin v<tag> + re-push protocol to fire release.yml. After this fix, v*.*.* tag pushes from auto-tag.yml fire release.yml automatically. Credentials live in the new auto-tag GitHub Environment (AUTO_TAG_APP_CLIENT_ID variable + AUTO_TAG_APP_PRIVATE_KEY secret). The App (Ferry Auto-Tag Bot, owned by nordscope-fi, installed on this repo only) has Contents: Read and write permission — minimum scope. No user-visible code changes.

[2.1.2] - 2026-05-14

Fixed

  • CI hygiene correction — upload-artifact and download-artifact were still on Node 20 in v2.1.1: the v2.1.1 pins actions/upload-artifact@v5 and actions/download-artifact@v6 were chosen based on release-notes wording ("supports Node v24.x") that turned out to mean compatible with Node 24 runners, not runs on Node 24 by default. The action.yml's runs.using: field is authoritative — for upload-artifact that flipped from node20 to node24 at v6.0.0; for download-artifact at v7.0.0. GitHub's release.yml run for v2.1.1 surfaced this with a Node.js 20 actions are deprecated annotation. Bumped both pins to their actual lowest Node-24 majors: upload-artifact@v5 → @v6, download-artifact@v6 → @v7. All other v2.1.1 pins (checkout@v6, setup-uv@v7, upload-pages-artifact@v5, deploy-pages@v5, action-gh-release@v3) were verified against action.yml and are genuinely on Node 24.

[2.1.1] - 2026-05-14

Changed

  • CI hygiene — Node 20 → Node 24 Actions runtime: bumped every Node-20 actions/* reference across all four workflows (ci.yml, release.yml, auto-tag.yml, docs.yml) to its lowest version that ships Node 24, ahead of GitHub's June 2026 runtime retirement. Specifically: actions/checkout@v4 → @v6, actions/upload-artifact@v4 → @v5, actions/download-artifact@v4 → @v6, actions/upload-pages-artifact@v3 → @v5, actions/deploy-pages@v4 → @v5, astral-sh/setup-uv@v5/@v6 → @v7 (also fixes the version drift in docs.yml), softprops/action-gh-release@v2 → @v3. pypa/gh-action-pypi-publish unchanged (Docker action, unaffected). No user-visible code changes; pure CI maintenance.

[2.1.0] - 2026-05-14

Features

  • Rollback engine (#10): new ferry rollback --output-dir <path> [--yes] [--force-unlock] CLI subcommand and a GUI "Rollback this migration" button on the migration-complete page. Reverses a recorded migration by deleting Ferry-created channels, roles, custom emoji, and Ferry-owned categories from the Stoat target server. Reads state.json for the entity IDs to delete. Idempotent re-runs: a 404 response is treated as "already deleted" via the new expected_404_ok=True path on _api_request. Supports partial-rollback resume via state.rollback_progress.rolled_back_ids (a new set field on MigrationState); the existing entity maps (channel_map / role_map / emoji_map) are never mutated by rollback (forensic preservation). Surfaces untracked-Ferry-suspect channels — channels present on the Stoat server but absent from state.channel_map, likely orphans from a crashed prior migration — in the confirmation gate for per-item opt-in. Channel deletes run concurrently (bounded by --max-concurrent-requests, default 5); roles and emoji are serialized on Stoat's shared /servers 5/10s bucket. Acquires the same [FERRY_LOCK:...] server-description marker as run_migration, so a concurrent rollback or migration cannot collide. Autumn-hosted attachments are not removed — no public DELETE endpoint exists for Autumn files (documented in known-limitations).

Fixes

  • GUI progress emit gap during DCE export (issue #23): the GUI used to freeze on "Checking for DCE binary..." for the entire DCE channel-enumeration phase, which on large servers can run several minutes before DCE prints its first per-channel progress line. Added three intermediate emits: "Verifying .NET 8 runtime..." (before detect_dotnet()), "Launching DiscordChatExporter..." (before the subprocess spawn), and "DiscordChatExporter started — enumerating channels..." (inside run_dce_export immediately after the subprocess is alive but before the first stdout line). Applied symmetrically to the GUI shell and the CLI/engine path.

[2.0.2] - 2026-04-21

Fixes

  • DCE version bump: DiscordChatExporter upgraded from 2.46.1 to 2.47.1. The upstream 2.46.1 release is no longer the latest tag and Ferry's hardcoded download URL was returning 404 for new users (reported in discussion #8 and issue #7). SHA-256 checksums for all three platforms (win-x64, linux-x64, osx-x64) added to dce_checksums.json; 2.46.1 entries retained for rollback.
  • Broken Stoat app link: app.stoat.chat is NXDOMAIN — canonical URL is https://stoat.chat/app. Fixed in README (#15, thanks @FedeltaMedia) and swept the remaining 3 references in docs/index.md and docs/getting-started/setup-stoat.md.

[2.0.1] - 2026-03-19

Fixes

  • Token sanitization wired (S1): SecureTokenStore now created in engine and used at error output boundaries — token values stripped from state.errors and event messages
  • DCE checksums populated (S10): SHA-256 hashes for DCE v2.46.1 (win-x64, linux-x64, osx-x64) — supply chain verification now active
  • GUI thread strategy (S7): Thread strategy dropdown added to GUI setup page
  • Forum index edit (S15): Re-runs edit existing forum index message via api_edit_message instead of creating duplicates
  • Fidelity scoring expanded (S18): 5 categories (messages 40%, attachments 25%, embeds 15%, replies 10%, reactions 10%) — up from 2

[2.0.0] - 2026-03-19

Security

  • Token security hardening (S1): SecureTokenStore for token masking, repr=False on tokens, NiceGUI binds to localhost, Stoat token cleared from storage
  • DCE binary verification (S10): SHA-256 hash verification for DiscordChatExporter downloads

Performance

  • Parallel message sends (S4): Cross-channel parallelism via asyncio.gather with ChannelResult accumulators
  • Adaptive rate limiting (S9): 429-frequency optimization with rolling window and auto-adjusting delay multiplier

Features

  • Thread strategy (S7): --thread-strategy flag with flatten/merge/archive modes
  • Message splitting (S3): Messages >2000 chars split with [continued K/N] markers instead of truncation
  • Delta migration (S19): --incremental flag for migrating only new messages since last run
  • Migration lock (S17): Advisory lock via server description prevents concurrent migrations
  • Fidelity scoring (S18): Quantified migration fidelity percentage in report

Fixes

  • Resume correctness (S2): completed_channel_ids set replaces fragile snowflake ordering
  • Emoji collisions (S6): Duplicate sanitized names get _2, _3 suffixes
  • Underline+bold (S3): **** collision collapsed to **
  • Cross-channel replies (S8): Text fallback annotation instead of silent drop
  • Banner auth (S11): Discord auth header for CDN downloads
  • Masquerade discriminator (S11): Truncated names append author ID suffix
  • DCE freshness (S11): Warn >7 days, error >30 days with --force override
  • Reaction counts (S12): Native mode appends original count annotation
  • Embed overflow (S3): Failed embeds reported with [N embed(s) could not be migrated]

Infrastructure

  • Separate message_map.json (S5): Reduces state.json size dramatically
  • Emoji in embeds (S6): Discovery scans embed description, title, and field values
  • Upload verification (S13): Optional --verify-uploads for post-upload size check
  • Forum index rebuild (S15): Index built during REPORT phase with actual migration data
  • Orphan detection (S16): --cleanup-orphans flag detects unreferenced Autumn uploads
  • Code signing (S20): CI pipeline prepared for macOS/Windows binary signing

Breaking Changes

  • State format v2: completed_channel_ids replaces last_completed_channel/last_completed_message
  • message_map stored in separate message_map.json file
  • v1 state files automatically migrated on first load (backup created)

[1.7.1] — 2026-03-18

Added

  • Known limitations guide: Centralized docs/guides/known-limitations.md listing every structural impossibility with what-Discord-has / what-Stoat-gets / workaround columns.
  • Pre-flight checklist: docs/guides/pre-flight-checklist.md — 10-step preparation guide preventing common migration failures.
  • Forum post index channel: Auto-generated forum-index channel per forum-derived category with pinned message listing all posts and message counts.

[1.7.0] — 2026-03-18

Added

  • Exponential backoff + circuit breaker: API retries use min(2^attempt, 60) + jitter instead of fixed 2s. Circuit breaker opens after 5 consecutive non-429 failures (30s pause). asyncio.Semaphore bounds concurrent requests.
  • Discord link rewriting: Jump links (discord.com/channels/...) rewritten to Stoat channel references. Invite links (discord.gg/...) annotated as expired. Covers all URL variants (canary, ptb, discordapp.com).
  • Edited message indicator: Messages with timestamp_edited now show *(edited)* after the timestamp prefix.
  • Attachment overflow handling: Messages with >5 attachments get text fallback listing skipped filenames instead of silent truncation.
  • Embed URL validation: Expired Discord CDN embed media URLs (thumbnail, image) are detected and stripped, preserving text content.
  • Markdown migration report: migration_report.md generated alongside JSON with human-readable summary table, errors, and warnings.
  • Server banner migration: Banner hash extracted from Discord API, downloaded from CDN, uploaded to Autumn, applied via api_edit_server.

[1.6.0] — 2026-03-18

Added

  • Dead-letter queue: Failed messages tracked as typed FailedMessage objects with Discord ID, error, and content preview. New run_retry_failed() re-processes failures using single-scan strategy.
  • Configurable reaction strategy: New reaction_mode config — "text" (default) appends [Reactions: emoji count] to content (zero extra API calls), "native" keeps Phase 9 behavior, "skip" ignores reactions entirely.
  • Per-member permission override warnings: User overrides (type=1) now counted per channel, surfaced in pre-migration review and report with workaround suggestion ("create single-user roles").
  • Inline embed field layout: Embed fields with inline=True grouped into rows with | separators (max 3 per row). Non-inline fields render on their own lines.
  • Orphaned Autumn asset tracking: Every upload tracked; after successful send, IDs marked as referenced. Post-migration report shows unreferenced file count.
  • Thread filtering by message count: New min_thread_messages config (default 0) excludes threads below the threshold. Filtered threads logged as warnings.
  • Post-migration validation: Optional validate_after phase compares Stoat server channel/role counts against state maps via api_fetch_server(). Reports discrepancies.

[1.5.0] — 2026-03-18

Added

  • Avatar pre-flight phase: New migration phase uploads all unique author avatars to Autumn before message migration, preventing broken masquerade avatars when Discord CDN URLs expire.
  • CDN URL expiration detection: Validates Discord CDN signed URLs during export validation and warns when attachment URLs have expired, with recommendation to re-export with --media.
  • Configurable checkpoint interval: New checkpoint_interval config field (default: 50) controls how often migration state is saved, with a 5-second time throttle to prevent I/O thrashing.
  • Timestamp preservation guide: New docs/guides/timestamps.md documenting why message timestamps change and the self-hosted MongoDB workaround.
  • Regression tests for audit-verified features (emoji phase ordering, ADMINISTRATOR permission mapping, deny-bit pipeline).

Fixed

  • Security: ADMINISTRATOR bit in deny context no longer incorrectly expands to ALL permissions. Other deny bits alongside ADMINISTRATOR are now correctly translated.
  • Security: Missing Discord token warning upgraded from status="progress" to status="warning" with explicit mention that private channels may become publicly visible.
  • Resilience: HTTP 413 from Autumn now produces a specific "File too large" error message with file size and limit, instead of a generic upload failure.
  • Resilience: Oversized attachments are pre-checked against size limits before upload attempt, with text placeholder injected into message content.
  • Resilience: Expired CDN URLs produce [Attachment expired: filename] placeholder in message content instead of silent failure.

[1.4.0] — 2026-03-09

Fixed

  • Category creation endpoint: Replaced non-existent POST /servers/{id}/categories and PATCH /servers/{id}/categories/{id} with correct PATCH /servers/{id} using the server's categories array property. Categories are now built locally with client-generated IDs and sent in a single PATCH call.
  • Emoji creation endpoint: Replaced non-existent POST /servers/{id}/emojis with correct PUT /custom/emoji/{autumn_id} using parent object ({"type": "Server", "id": server_id}). The Autumn file ID is now the emoji's permanent Stoat ID.
  • Channel name truncation: Reduced from 64 to 32 characters to match Stoat API maxLength constraint.
  • Message nonce deprecated: Replaced nonce body field with Idempotency-Key HTTP header for message deduplication. Resume logic unaffected (keyed by Discord message ID).

Added

  • String sanitization module (migrator/sanitize.py): truncate_name() (generic 32-char truncation) and sanitize_emoji_name() (lowercase, [a-z0-9_] only, 32-char max, fallback to "emoji").
  • Role name truncation: Role names truncated to 32 characters before API call.
  • Category title truncation: Category titles truncated to 32 characters.
  • Masquerade name truncation: Display names truncated to 32 characters.
  • Emoji name sanitization: Custom emoji names sanitized to ^[a-z0-9_]+$ pattern and 32-character limit.
  • extra_headers support: _api_request() now accepts optional extra HTTP headers (used for Idempotency-Key).
  • 14 new tests: sanitize helpers (12), masquerade truncation (1), role name truncation (1) — 440 total passing.

Changed

  • api_create_emoji() signature: Now takes emoji_id (Autumn file ID), name, and server_id instead of name and parent (Autumn ID).
  • api_send_message() signature: nonce parameter replaced with idempotency_key.
  • api_create_category() and api_edit_category() removed: Replaced by api_upsert_categories().
  • Category management rewrite: run_categories() generates category IDs client-side and sends a single PATCH. run_channels() rebuilds the categories array for channel assignment without a server fetch.
  • cli.py build command: Updated to use api_upsert_categories() with client-generated IDs.
  • Documentation: Updated stoat-api-notes.md with correct endpoints, string limits, and deprecation notes.

[1.3.0] — 2026-03-01

Added

  • Discord permission migration: Fetches guild roles and channels via Discord REST API, translates permission bitfields from Discord bit space to Stoat bit space, and applies role permissions during Phase 4 (ROLES). ADMINISTRATOR expands to all individual Stoat permissions.
  • Channel permission overrides: Per-role and @everyone channel overrides fetched from Discord API and applied during Phase 6 (CHANNELS).
  • NSFW flag migration: Channel NSFW status fetched from Discord API and set during channel creation.
  • Pre-creation review: Blocking confirmation step shows summary (roles, channels, categories, emoji, messages) and warnings before creating anything on Stoat. GUI shows dialog; CLI shows Rich table.
  • Post-migration checklist: Enhanced report includes actionable next steps (verify channels, review permissions, check emoji, invite members).
  • Server blueprint export/import: ferry export-blueprint converts a DCE export directory into a reusable JSON blueprint. ferry build creates a Stoat server from a blueprint or preset template.
  • 3 preset server templates: Gaming, Community, and Education — each with roles, permissions, categories, and channels.
  • Discord metadata persistence: discord_metadata.json stores translated permissions and NSFW flags alongside state.json for resume support.
  • 4 new Stoat API functions: api_set_role_permissions, api_set_server_default_permissions, api_set_channel_role_permissions, api_set_channel_default_permissions.
  • 64 new tests: permissions (10), metadata (5), Discord client (6), API (4), structure (8), engine (4), review (10), reporter (4), blueprint (9), CLI (3), GUI (2) — 426 total passing.

Fixed

  • Remove hardcoded local path from .claude/settings.json for open-source readiness.
  • Update README "How It Works" to reflect 1-Click Migration as the default workflow.
  • Update GUI walkthrough: rewrite Setup screen for two-mode layout, add Export screen docs, fix phase count (11 → 12).
  • Fix stale "11 phases" references in first-migration guide and brief.
  • Add "What is Stoat?" section to README with signup and setup links.
  • Explain what credentials are needed and where to find them in README Step 1.
  • Replace jargon "masquerade" with plain language in all user-facing docs.
  • Fix "Stoat bot token" → "Stoat user token" in first-migration prerequisites.
  • Fix channel @everyone permission overrides silently dropped during migration.
  • Fix ferry build NameError when printing completion message.
  • Fix PyInstaller binary missing template JSON files.

[1.2.1] — 2026-03-01

Added

  • CLI ToS disclaimer: Orchestrated mode now prompts for Discord ToS acknowledgment. Use --yes / -y to skip in scripts.
  • GUI smart resume: Export page detects cached exports and offers [Use Cached] or [Re-export] choice.
  • DCE download retry: download_dce() retries once on network error before failing.
  • Built-in token help: "How to find these?" opens an inline dialog with step-by-step instructions instead of linking to external wiki.

[1.2.0] — 2026-02-28

Added

  • Phase 0 — DCE Orchestration: Ferry can now download and run DiscordChatExporter automatically. Users provide a Discord token and server ID instead of manually exporting. Existing offline mode (--export-dir) still works.
  • Streaming JSON parser: stream_messages() uses ijson to parse messages one at a time, keeping memory usage flat for large exports
  • metadata_only parsing mode: parse_export_directory(metadata_only=True) skips message loading for fast validation
  • CLI orchestrated mode: --discord-token + --discord-server flags for automatic export, mutual exclusion with --export-dir
  • GUI orchestrated mode: Mode toggle (Orchestrated / Offline), Discord credential inputs, ToS checkbox, /export page with progress bar
  • Single-pass author name collection: validate_export() now collects author names during validation, eliminating a second full scan
  • New dependency: ijson>=3.0 for streaming JSON parsing
  • 49 new tests: streaming parser, metadata_only, CLI orchestrated mode, GUI phase labels — 355 total passing

Changed

  • Engine phases: Now 12 phases (EXPORT through REPORT); validate uses metadata_only=True
  • All message-consuming phases (messages, emoji, roles) use stream_messages() when exports were parsed with metadata_only=True
  • Runner lifecycle events: Engine owns started/completed events; runner only emits progress
  • Discord token security: GUI clears token from persistent storage in finally block (covers failure paths)
  • validate_discord_token: Now catches aiohttp.ClientError for network failures
  • Documentation rewrite: 5 docs pages updated to show orchestrated mode as primary workflow

Fixed

  • mypy webview error: Added type: ignore[import-not-found] for optional webview import

[1.1.0] — 2026-02-28

Changed

  • GUI setup page redesign: Step indicator wizard (Configure → Validate → Migrate → Done), pre-flight checklist banner with prerequisite links, dark navy header, IBM Plex Sans font, fade-in animations
  • Hosted/self-hosted toggle: Replaces bare "Stoat API URL" input with a toggle defaulting to Official Stoat; self-hosted URL field appears only when needed
  • Inline browse button: Folder picker icon moved inside the export path input field
  • Amber action buttons: Primary actions use amber-700 instead of blue-600
  • State restoration: All setup fields persist across back-navigation from validate page
  • URL scheme validation: Self-hosted URLs must start with http:// or https://
  • Step indicators on all pages: Validate and migrate screens show progress through the wizard

[1.0.1] — 2026-02-28

Fixed

  • FERRY_MIN_PERMISSIONS was wrong since v1.0.0: Bits 24/25 were set instead of bit 20 (ViewChannel). Rewritten as bitwise OR expression for clarity. Added ManageCustomisation (bit 4) for emoji creation. Correct value: 1,022,361,624. If you migrated with v1.0.0, grant ViewChannel and ManageCustomisation permissions manually on servers created by Ferry.
  • Forum/media channel headers: Thread types 15 (GUILD_FORUM) and 16 (GUILD_MEDIA) now get "[Forum post migrated from #parent]" instead of generic "[Thread migrated from #parent]"
  • CLI validate ETA: Now respects --rate-limit option instead of hardcoding 1.0s
  • Suppressed warnings notice: Non-verbose CLI migration now prints "{N} warning(s) suppressed — run with -v to see details"
  • Specific field validation errors: GUI setup page names which fields are missing instead of generic "all required"
  • ~10 docs inaccuracies: wrong state filename, stale GitHub URL, missing mypy in verification, DMG→ZIP for macOS, false "interactive prompt" claim, missing --rate-limit docs, missing FERRY_STORAGE_SECRET docs

Added

  • Structured warning/error types: All state.warnings and state.errors dicts now include a "type" field for downstream filtering
  • GUI folder picker: Browse button using pywebview native folder dialog (disabled gracefully without pywebview)
  • GUI server name input: Optional server name in Advanced Options
  • Accessible phase chips: Text indicators (✓ ● ✗ — ⚠) on phase chips so status is not conveyed by color alone (WCAG 1.4.1)
  • 8 new tests: API retry (network error, 502/503), state backward compat, embed/sticker/poll e2e — 306 total passing

[1.0.0] — 2026-02-28

First stable release. All 11 migration phases implemented with 298 passing tests.

Added

  • Rich CLI progress bars: Live dashboard with phase progress bar, per-channel message progress bar with ETA, and running stats (messages/errors/warnings)
  • Poll migration: DCE poll data parsed and flattened into message content as formatted text
  • Sticker image upload: Locally downloaded sticker images uploaded as message attachments (text fallback for missing/Lottie stickers)
  • Embed media upload: Embed thumbnails and images from local --media exports uploaded to Autumn and attached to embeds
  • Forum categories: Forum/media channel threads (type 15/16) grouped into dedicated categories named after the parent forum
  • Role rank ordering: Best-effort second pass sets role rank from DCE position data after creation
  • Permission pre-check: CONNECT phase verifies server accessibility when using --server-id (best-effort, non-fatal)
  • skip_threads GUI checkbox: Exposed in Advanced Options alongside existing skip toggles
  • attachments_uploaded counter: Accurate attachment count in state and reports (replaces deduplicated cache length)
  • GitHub Actions docs workflow: docs.yml builds and deploys MkDocs Material site to GitHub Pages on push to main
  • SVG project logo and asset build instructions for platform icons
  • 12 new tests covering polls, stickers, embeds, forum categories, role rank, permission pre-check — 298 total passing

Fixed

  • completed_at timing: Report timestamp now set before generate_report() runs, giving correct duration
  • silent messages: All migrated messages sent with silent: true to prevent notification spam
  • Missing skip types: Call and ChannelIconChange messages now skipped during import
  • ConnectionError shadowing: Renamed to StoatConnectionError to avoid shadowing Python builtin
  • GUI resume race condition: Migration start gated behind asyncio.Event until user clicks Resume or Start Fresh
  • Embed/sticker upload errors logged: Failures now recorded as warnings instead of silently swallowed
  • Version mismatch: __init__.py (0.9.0) and pyproject.toml (0.10.0) now aligned to 1.0.0
  • Docs quality pass: ~30 fixes across all 13 documentation pages — wrong port number, stale stoat-py code examples, missing v0.9.0 flags (--dry-run, --max-channels, --max-emoji), incorrect "Skip threads in GUI" claims, placeholder GitHub URLs, wrong report format, stale resume instructions, inaccurate MigrationEvent/MigrationState descriptions
  • GUI placeholder URL: Changed api.revolt.chat to api.stoat.chat in the Stoat API URL input field

Changed

  • README: Download links use GitHub Releases /latest/ pattern, feature table synced with docs, self-hosted tips link added
  • Classifier: Updated from Beta to Production/Stable

[0.9.0] — 2026-02-27

Added

  • Dry-run mode: --dry-run CLI flag and GUI checkbox run all phases without API calls, producing synthetic dry-* IDs for validation
  • Configurable server limits: --max-channels and --max-emoji CLI options for self-hosted Stoat instances with custom limits
  • Permission bootstrap: Automatically patches server default role with ferry minimum permissions on server creation
  • GUI resume detection: Migrate page detects previous state.json and offers resume/fresh-start choice
  • GUI attachment size display: Validate summary now shows total attachment size in human-readable format
  • 8 new integration tests in test_migrator.py covering dry-run, permission bootstrap, and configurable limits — 278 total passing

Fixed

  • ChannelPinnedMessage sent as content: Pin notification messages are now silenced and the referenced message is queued for re-pinning in the pins phase
  • Failed messages marked as completed: Removed duplicate last_completed_message assignment that caused failed messages to be skipped on resume
  • Missing periodic state saves: Messages phase now saves state every 50 messages and after each channel completes

Changed

  • Shared aiohttp session: Single engine-managed ClientSession replaces per-phase sessions for better connection reuse
  • Removed stoat-py dependency: Project already uses custom raw aiohttp API layer; the SDK was unused weight
  • Removed aiofiles dependency: Listed but never imported anywhere in the codebase
  • Engine refactor: Extracted _run_phases from run_migration for readability; fixed silent return-value bugs

[0.8.1] — 2026-02-27

Fixed

  • skip_threads flag wired to nothing: FerryConfig.skip_threads now filters thread/forum exports in both the channels and messages phases
  • GuildMemberJoin and ThreadCreated not skipped: Added to _SKIP_TYPES so system noise messages are silently dropped per DCE format spec
  • Thread header messages missing: Flattened thread channels now get a [Thread migrated from #parent] system message injected before their content
  • 200-channel limit not enforced: Channels exceeding the Stoat 200-channel limit are now truncated, dropping thread channels first to preserve main channels
  • GUI "Open Report" button broken: Fixed glob pattern to match the actual migration_report.json filename
  • Animated emoji warning missing: Emits a warning when uploading animated emoji (animation is lost on Stoat)
  • Validate-phase emoji count undercount: validate_export now counts custom emoji from message content in addition to reactions

Added

  • 15 new tests covering all 7 bug fixes — 270 total passing

Changed

  • CI pipeline (.github/workflows/ci.yml): lint + type check + test on push/PR with Python 3.10–3.13 matrix via uv, concurrency groups to cancel stale runs
  • Release pipeline (.github/workflows/release.yml): tag-triggered PyInstaller builds for Windows (.exe) and macOS (.app), GitHub Release with attached binaries, PyPI publish via OIDC trusted publisher
  • PyInstaller spec (ferry.spec): NiceGUI asset collection, pywebview native mode support, dynamic version from __init__.py, platform-specific icon fallback
  • Getting Started documentation (docs/getting-started/): 4 new pages — install (platform tabs for Windows/macOS/Linux), export-discord (5-step DCE guide with warnings and FAQ), setup-stoat (API URL + token + permissions), first-migration (end-to-end GUI/CLI walkthrough)
  • Docs landing page (docs/index.md): expanded from stub to full landing page with feature table, timing estimates, and guide links
  • Guides documentation (docs/guides/): 5 new pages — gui-walkthrough, cli-reference, large-servers, self-hosted-tips, troubleshooting
  • Reference documentation (docs/reference/): 3 new pages — architecture, stoat-api-notes, dce-format
  • GitHub issue templates (.github/ISSUE_TEMPLATE/): bug report (structured form), feature request, config.yml (template chooser with Discussions link)
  • PR template (.github/PULL_REQUEST_TEMPLATE.md): type-of-change checkboxes and checklist

[0.8.0] — 2026-02-27

Added

  • NiceGUI web GUI (gui.py): 3-screen migration workflow — Setup (config form with rate limit slider, skip toggles, advanced options), Validate (export summary table, warnings, ETA estimate, blocks on critical warnings), Migrate (live dashboard with phase chips, progress bar, stats counters, scrolling log, pause/resume, cancel with confirmation dialog, completion card with "Open Report")
  • Pause/cancel support: pause_event and cancel_event on FerryConfig, engine checks cancel between phases and saves state, message rate limiter respects pause/cancel flags
  • 11 GUI tests covering helper functions (ETA, msgs/hr, summary), cancel-stops-migration, cancel-saves-state, pause-blocks-rate-limiter — 257 total passing

Fixed

  • Hardcoded NiceGUI storage secret replaced with env var / random fallback (FERRY_STORAGE_SECRET)

[0.7.0] — 2026-02-27

Added

  • CLI interface (cli.py): full Click implementation with migrate and validate subcommands, Rich progress display with phase status icons, export summary table, ETA estimate, .env support via python-dotenv, environment variable fallbacks (STOAT_URL, STOAT_TOKEN), --resume / --skip-* / --rate-limit flags, MigrationError and KeyboardInterrupt handling with exit codes
  • 14 CLI tests using Click's CliRunner with mocked engine — 246 total passing

[0.6.0] — 2026-02-27

Added

  • EMOJI phase (migrator/emoji.py): extract unique custom emoji from reactions + content regex, upload to Autumn, create on server with 2s rate-limit delay, resume-safe via state.emoji_map
  • MESSAGES phase (migrator/messages.py): full 9-step per-message pipeline — attachment upload (max 5), content transforms (spoilers→underline→mentions→emoji→timestamp→stickers), masquerade with lazy avatar upload/caching, embed flattening, reply references, empty message placeholder, 2000-char truncation, nonce deduplication (ferry-{msg_id}), pin/reaction queuing, per-channel resume with numeric Snowflake ID comparison
  • REACTIONS phase (migrator/reactions.py): apply queued reactions with 20-per-message Stoat limit, fire-and-forget error handling
  • PINS phase (migrator/pins.py): restore pinned messages from queue, fire-and-forget error handling
  • 4 API functions (migrator/api.py): api_create_emoji, api_send_message, api_add_reaction (URL-encoded emoji), api_pin_message
  • 73 new tests across messages (43), emoji (12), API (5+), reactions (6), pins (5) — 233 total passing

[0.5.0] — 2026-02-26

Added

  • Stoat API wrapper (migrator/api.py): thin async HTTP layer with retry on 429/5xx, network error handling, and 204 No Content support
  • SERVER phase (migrator/structure.py): creates or attaches to Stoat server, uploads guild icon via Autumn
  • ROLES phase (migrator/structure.py): extracts unique roles from exports, creates with British colour, skips @everyone
  • CATEGORIES phase (migrator/structure.py): deduplicates and creates server categories
  • CHANNELS phase (migrator/structure.py): type mapping (text/voice/thread/forum), voice fallback to text, thread name flattening, two-step category assignment, make_unique_channel_name collision prevention within 64-char limit
  • 33 new tests across API wrapper (10), structure phases (23) — 160 total passing

[0.4.0] — 2026-02-26

Added

  • Autumn uploader (uploader/autumn.py): file upload with size validation per tag, retry on 429/5xx with backoff, and upload_with_cache helper backed by state.upload_cache
  • CONNECT phase (migrator/connect.py): discovers Autumn URL via GET /, verifies auth token via GET /users/@me, stores autumn_url in migration state
  • Engine default phases (core/engine.py): _DEFAULT_PHASES dict for wiring real phase implementations — overrides take priority, then defaults, then skip
  • 16 new tests across uploader (9), connect (6), and engine (1) — 127 total passing

[0.3.0] — 2026-02-26

Added

  • Migration engine (core/engine.py): 11-phase orchestrator with phase injection for testing, resume support (skip completed phases), and config-based skip flags for emoji/messages/reactions
  • State persistence (state.py): atomic save/load with JSON round-tripping, crash recovery (state saved on error), author name tracking, counter fields for reactions/pins/attachments
  • Report generator (reporter.py): produces migration_report.json per brief §12 with summary counts, ID maps, timing, warnings, and errors
  • Event callback (core/events.py): EventCallback type alias and "skipped" status for phase events
  • 40 new tests across engine (18), reporter (15), and state (7) — 111 total passing

[0.2.0] — 2026-02-26

Added

  • DCE parser (parser/dce_parser.py): parse DiscordChatExporter JSON exports into typed models, with thread/forum inference from filename patterns and export validation (rendered markdown detection, missing media, channel/emoji limits)
  • Content transforms (parser/transforms.py): spoiler conversion, mention/emoji/underline remapping, embed flattening (Discord→Stoat), timestamp formatting, sticker placeholders — all code-block-aware
  • Typed data models (parser/models.py): 10 dataclasses (DCEGuild, DCEChannel, DCEAuthor, DCERole, DCEAttachment, DCEEmoji, DCEReaction, DCEReference, DCEMessage, DCEExport)
  • Test fixtures: 5 realistic DCE JSON files covering text channels, threads, forums, edge cases, and rendered markdown detection
  • 71 passing tests across parser (27) and transforms (42) with full coverage of edge cases

[0.1.0] — 2026-02-26

Added

  • Project scaffolding and Claude Code configuration
  • Migration engine skeleton (11-phase architecture)
  • CLI skeleton (Click)
  • GUI skeleton (NiceGUI)
  • DCE parser data models (stubs)
  • Migration state management dataclass