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
.envDiscord tokens for offline exports with a note namingDISCORD_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, thenkeyvaluepairs, then theauthrow, undersession.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--tokenhelp text of three commands now name the new location. The guide also warns against copyinguserIdfrom the same preview by mistake.
[2.43.1] - 2026-10-11
Changed
- Weekly dependency update (Dependabot #1137).
nicegui3.17.1 → 3.18.0 (the GUI shell) andaiohttp3.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/checkout6.1.0 → 7.0.1,actions/cache5.1.0 → 6.1.0,actions/upload-artifact6.0.0 → 7.0.2,actions/download-artifact7.0.0 → 8.0.2 andastral-sh/setup-uv7.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.ymlused 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.ymlanddocs.ymlalso state their permissions, withdocs.ymlgranting 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_classschemawithfailure_reasonstream-runawayin 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,
npxdownloaded 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 inconfig/agent-compat/context7/now records an integrity hash for the helper and for all 104 packages in its tree. The installer runsnpm ci --ignore-scriptsagainst it, andnpm cistops if any download does not match. The launcher then starts the installed copy with the samenodethat runs the launcher, so it no longer usesnpxor searchesPATHfor 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: runnode 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 repairrecreates 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-threadsand--min-thread-messagesleave no other trace, and the warning names it.--dry-runstill sends nothing. - Check the destination ids saved in
state.jsonbefore resuming or recovering with them (#970).--resume,ferry retry,ferry rollback,ferry repair,ferry checkandferry statsread 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 examplechannel_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 retryskips 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 repairleaves 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, inferry migrate --resumeand in the GUI rollback.ferry retryandferry rollbacknow 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_ferrymodules 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_namefield (#154). The three warnings that concern one channel (http_attachment,rendered_markdownandempty_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 tostate.jsonandmigration_report.json, where it is masked for secrets exactly as the message is, because a channel name comes from the export. Olderstate.jsonfiles 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
httpsDiscord 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 onlycdn.discordapp.comandmedia.discordapp.netoverhttpson 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 noDOTNET_ROOT. Of theDOTNET_*settings onlyDOTNET_SYSTEM_GLOBALIZATION_INVARIANTis 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 repairnow 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--incrementalrun 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 textNo 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-runlists what it would remove and deletes nothing. The JSON output gainsremoved_duplicate_indexesunderactions. 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. Theavatars,role-iconsandthreadsfolders are checked the same way: if any of them, or a folder insidethreads, 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,localhostor[::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 use127.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.jsonfrom another server no longer feeds its role and channel permissions into a migration, on a resume or a fresh run.--resumestops 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 repairand 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 andstate.jsonare 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. Theferry checkoutput 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 withPermissionError(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.tmpfile, 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-cliornpxto the operating system, which searchedPATH, 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 helperresolve-executable.mjsskips 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. Wherenpxis a node script, the Context7 launcher runs it with the node that started the launcher, so the script's#!/usr/bin/env nodeline does not searchPATHagain. The Context7 server it starts gets aPATHholding only the safe entries, and npx starts in the home folder, because npx puts thenode_modules/.binfolder of its working folder and every parent ahead ofPATH. Its ownnodelookup 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/binand npm global installs work as before. The reviewer runtime gains one file, so reinstall it frommain.
[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_pathmessages (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
--tokenor--discord-tokenon the command line, where a token is saved in shell history and can show up in process listings. They readSTOAT_TOKENandDISCORD_TOKENfrom the environment or a.envfile, and--helpformigrate,build,rollbackandprobenow 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_TOKENenvironment variable instead of the--tokenargument (#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 astate.jsonwritten 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 oldstate.jsonfiles 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.Zcomment, and pin the build backend tohatchling==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 thegithub-actionsecosystem and proposes SHA bumps weekly. The backend pin is bumped by hand. - Document in
scripts/agent-compat/verify-all.mjsthat 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 sharescripts/agent-compat/session-context.mjs. - The local web interface no longer interprets
X-Forwarded-ForandX-Forwarded-Protoheaders (#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 thevibecommand-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,*.pemand*.key(#979). None are tracked today, so this only stops an accidentalgit addfrom 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:npxcannot 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 -fdand-d -f, andgit checkout .. It no longer flagsgit restore -S ., which only touches the index, and it reads a value glued torestore -sas that value, so it cannot pass for-S. It sees through the wrappersenv(includingenv -S),command,exec,timeoutandsudowith options, and looks insidebash -c(also with--),sh -c,eval,$(...)and backticks (also inside double quotes), subshells andthenblocks. 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 aspython -corssh,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,checkandbackfill-rolesnow 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 buildgot 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 buildnow 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.mjsso 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_failedwarning and failure reason while preserving the no-upload check (#1079). - Require the animated-emoji test to check the
animated_emojiwarning, 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_failedtype 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.mdnow works, and quoted paths still go through the containment check. - An
rgorgit grepsearch 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/shand refuses anything a shell would expand, chain or redirect. An unquoted wildcard such asrg -n -- foo.* README.mdis 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)ora|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/shand checks that no file is created. - The reviewer runtime gains one file,
shell-words.mjs. Reinstall the runtime frommainso 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-verifybypass 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
iconUrlis refused with anunsafe_media_pathwarning instead of being uploaded. A named icon that is not present now warnsicon_file_missinginstead of being skipped with no output at all, which is what kept the resolution bug invisible for as long as it did. A remotehttporhttpsspelling 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
iconand the other only that some event had statuswarning, 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 theicon_upload_failedtype. 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.pydrives the realrun_serverundersandbox-execand reports whether bytes from outside the export root reached the upload boundary. Its runner follows the same argument and exit-code contract asrun-955.sh, so exit 1 means regression and the scratch directory is a required argument. It reusessandbox-955.sbrather 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_inflatedwarning 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 ownchannel_default_perm_failedorchannel_role_perm_failedwarning. - The Vibe reviewer no longer sees the owner's personal skills. Vibe reads user-level skills
from the
.agentsfolder in the home directory, and that lookup ignoresVIBE_HOME. The reviewer now also pointsHOMEat 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
@everyoneoverwrite 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 repairsends 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 achannel_override_admin_inflatedwarning naming the channel. Servers already migrated keep whatever they were given: neitherferry checknorferry repairlooks 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-2model 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.vibehooks, 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-brainstormrestarted 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_codeon Claude, and Claude Code's Bash result has no such field. Its result carriesstdout,stderr,interrupted,isImageandbackgroundTaskId, 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_responseand a top-levelerrorstring whose first line isExit 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 anexit_codefield 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: Nstatus line, which arrives intool_response.llmContentin the hook event that follows a successful tool call (PostToolUse) and in the top-levelerrorstring in the event that follows a failed one (PostToolUseFailure). A failure event has notool_responseat 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 fromgrep,rg,diffandtest, 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_manualandserver_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_resetstays 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_resetwarning. Owner-edited defaults and metadata-less output directories warnserver_default_manualwithout 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 warnserver_default_check_failedand 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_urlskips recreation with the existingemoji_missing_mediawarning 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.pyis 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_pathwarning 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_pathwarning through the correct per-channel or state accumulator, and count skipped attachments._build_masqueradereturns an escape flag so both send routes (per-message and merged-thread) record the warning. Escaped skips never enterfailed_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_embedandhandle_stickersdrop 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:
nicegui3.16.0 → 3.17.1 (the GUI shell),multidict6.7.1 → 6.9.0 andyarl1.24.5 → 1.25.1 (both underaiohttp). Toolchain and build:ruff0.16.5 → 0.16.8 andpyinstaller6.22.2 → 6.22.3 (withpyinstaller-hooks-contrib2026.6 → 2026.7). Ruff 0.16.8 now reportsSIM117on six nestedwithblocks 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
.agentswith 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_pathsmodule 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
llmContentandreturnDisplaywhile 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.mjsruns 12 static checks mirroring the Codex readiness structure, andvibe-setup.shorchestrates install and readiness. The installer now mentions both paths. -
Qwen readiness checker and setup entrypoint.
qwen-readiness.mjsruns 12 static checks mirroring the Vibe readiness structure, andqwen-setup.shorchestrates install and readiness. The installer now mentions both the Vibe and Qwen paths. -
Qwen Code now gets its own instruction file.
QWEN.mdcarries the project rules for the Qwen host, the wayCLAUDE.mddoes for Claude. It is gitignored, linked intonew-worktree.shworktrees, copied by.worktreeinclude, named inAGENTS.md, and the worktree contract check now requires the link. -
Brainstorm evidence now runs on the Qwen host.
.qwen/settings.jsonregisters 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.mjsreads 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.jsonentry 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-contractwith a--rootflag 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-shipand/df-chunk-reviewrun 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 feedbackcommand 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-2andqwen3.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.jsonfromconfig/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-critiquegains 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
uvupdates 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:nicegui3.12.0 → 3.16.0 (the GUI shell),click8.3.3 → 8.4.2 (the CLI),ijson3.5.0 → 3.5.1 (incremental parsing of the chat export),certifi2026.4.22 → 2026.7.22,python-dotenv1.2.2 → 1.2.3, andyarl1.23.0 → 1.24.5. Toolchain:ruff0.15.13 → 0.16.4,mypy2.1.0 → 2.3.1 (withlibrt0.11.0 → 0.15.0 andast-serialize0.4.0 → 0.8.0),pytest9.0.3 → 9.1.1,pytest-asyncio1.3.0 → 1.4.0, andaioresponses0.7.8 → 0.7.9. Build and docs:pyinstaller6.20.0 → 6.22.2 (withpyinstaller-hooks-contrib2026.5 → 2026.6) andmkdocs-material9.7.6 → 9.7.7.nicegui3.16.0 dropped itslxmldependency, solxml6.1.0 andlxml-html-clean0.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.mdanddocs/reference/stoat-api-notes.mdas 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
/healthdeployment 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.tomland removes the old file, butcheck.mjskept asserting the old path, soagent-check.shfailed 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.comandgithub.comdestination 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_idreturned 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
DuplicateSendErrorare 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.jsonand the markdown report now includemerge_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.getsourcesource-string assertions intests/test_gui.pyare replaced by NiceGUIuser-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.ymlenables weekly grouped updates for theuvecosystem. 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 checknow detects renamed emoji. The name Ferry creates for each emoji is recorded instate.jsonand compared against the server on check, producing anemoji_renamedwarning 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, wrapsclaude -pwith--json-schemaand no tool access (--allowedTools ''), returning findings in the samecode_review_findingsschema 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-Remainingon 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 falseflag. 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
silentlyindocs/guides/known-limitations.mdfailed 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.shwrote 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 runsplain-english initto 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-documentskill 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 theexploreread-only subagent Vibe exposes. - The session pre-flight skill is now in the on-demand list.
/df-startwas 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 execCLI 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
gatesjob runsagent-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 repairrecreates 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 repairrestores 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--incrementalre-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
/toolsgains 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 repairrestores a missing emoji. Whenferry checkreports 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
/toolsgains 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
/toolsadds: - Check: verify a finished migration and see a per-entity pass or fail table,
with the same verdict
ferry checkgives. - 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_PROXYis now honoured as a fallback for HTTP and HTTPS. DiscordChatExporter, which Ferry runs as a subprocess, readsALL_PROXY; Ferry did not, so a user whose only proxy setting wasALL_PROXYgot a successful export followed by every later phase connecting direct and failing. Ferry now uses theALL_PROXYproxy for http and https when no scheme-specificHTTP_PROXY/HTTPS_PROXYis set, so a singleALL_PROXY=http://host:portreaches every phase. A scheme-specific proxy still wins, an explicitly-emptied scheme (HTTPS_PROXY="") is not rescued by the fallback,NO_PROXYandFERRY_DISABLE_PROXYstill apply, and the proxy credential is stripped from logs and the connection URL as before. SOCKS proxies remain unsupported, including a SOCKS value inALL_PROXY, which is reported in the startup notice and inferry tls-check. Closes #141.
[2.22.0] - 2026-08-20
Added
ferry backfill-rolescorrects 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 withdiscord_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 fromrun_rolesinto a shared helper so the command orders by exactly what the migration built. Closes #388.
[2.21.0] - 2026-08-20
Added
ferry repair --jsonprints a machine-readable outcome document. Alongside repair's human output and its exit code,--jsonemits 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-repairferry checkof the live server. Under the flag all human output goes to stderr, so stdout stays parseable, and the document is printed throughclick.echo(never the Rich console, which wraps at 80 columns and can split a string value). The exit-code contract is unchanged. A newRepairOutcomedataclass carries the result out ofrun_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 --deepnow 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 buildnow applies blueprint and template role ordering. The build path sentrankthroughapi_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 callsapi_edit_role_ranksonce 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_rolespreviously saved state every 10th role, andrun_channelshad 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 persiststate.jsonafter every structural create call, matching the existing server-create pattern. During structural phasesmessage_mapis empty, so each write is cheap. Fixes #389.
[2.19.15] - 2026-08-19
Changed
--incrementalhelp 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_eventwas set afterrun_migrationreturns 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_runearly-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 populatesstate.avatar_cachewith 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_modeistext, 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 checksummary 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.maskedrendered 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 base64proxy_authorizationheader derived from it carried the same leak.SecureTokenStorenow carries a per-key full-mask policy:registerandregister_secrettake a keyword-onlyfully_maskflag, andmaskedreturns bare****for those keys.core/http.py's_strip_userinforegisters bothproxy_passwordandproxy_authorizationas fully masked. Stoat and Discord tokens keep the existing tail shape. Fixes #149.
[2.19.8] - 2026-08-18
Fixed
- Credentials embedded in
--stoat-urlas HTTP basic-auth userinfo no longer reach API error messages unredacted. Astoat_urllikehttps://user:pass@hostwas interpolated verbatim into MigrationError and StoatConnectionError text at eight sites. Theferry buildpath registers no secret, so nothing downstream could scrub it. A newredact_urlhelper incore/http.pystripsuser:password@before the URL reaches any error message. Fixes #276. - The nicegui dependency floor no longer sits two majors below a symbol
gui.pyimports at module load.pyproject.tomldeclarednicegui>=2.0butgui.pyimportsClientConnectionTimeout, which first exists in nicegui 3.0.0.uv.lockpins 3.12.0 and CI installs from the lock, so the floor was never exercised; an environment resolving nicegui 2.x failed withImportErroron import. The floor is nownicegui>=3.0with an inline comment naming the symbol. Fixes #382. - The
--versiontest no longer fails when a NiceGUI page-test module is collected before it.test_version_is_read_from_module_attribute_not_package_metadataaccessed thediscord_ferry.clipackage attribute forimportlib.reload, but a preceding NiceGUI page-test re-importsdiscord_ferryas a fresh package whosecliattribute is never re-set. The test now holds the submodule viaimportlib.import_moduleand 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_importsparses the floored dependencies inpyproject.tomland 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 nostate.jsonraisedStateErrorthrough the generic handler with no useful message. The else branch now resets the flag, and a dedicatedexcept StateErrorhandler 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 14df-*skills. A drift verifier checks generated state. Run./scripts/agent-install.shto generate,./scripts/agent-check.shto 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 inrole_mapwas skipped on resume. Moved to the attributes pass alongside hoist and icon, gated onroles_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_embednow 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_embednow 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_embednow 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_attachmentfilters byused_for.idnot existing, so reusing a cached id for a second message returns 404 NotFound.upload_with_cachenow acceptsskip_cacheand all three attachment call sites inmessages.pypass it. Fixes part of #349. -
Embed descriptions that would exceed Stoat's
validate_sumceiling are now budgeted. The server sumscontent.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 inembeds_dropped. Closes #349.
[2.19.4] - 2026-08-18
Fixed
--validate-afternow runs the same verification asferry checkinstead 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 callsrun_check, which verifies every entity by ID, checks names, and samples message tails. Thevalidation_resultsdict instate.jsonchanges 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. Bothferry check --jsonand--validate-afteruse it. The duplicate_check_report_as_dictincli.pyis removed. -
scrub_documentnow masks thedetailfield in validation results. The old cardinality shape held no free text. The new shape'sresults[].detailcan embed a stringified exception carrying proxy credentials viaaiohttp.ClientHttpProxyError. Bothvalidation_resultsinstate.jsonandvalidationinreport.jsonare covered.
[2.19.3] - 2026-08-18
Fixed
ferry proberendered 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 raisedMarkupErrorand gave exit 1 with no table at all. Now wrapsc.nameandc.detailinescape(), matching_render_check_reportwhich already did this.--jsonwas never affected. Fixes #384.
[2.19.2] - 2026-08-18
Fixed
- A bool or NaN
retry_afterin a 429 body defeated backoff instead of falling back to the default delay.float(True)gave 0.001 s andfloat(False)gave 0.0 s, becauseboolis a subclass ofint. NaN survived themax/minclamp 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 inuploader/autumn.py. The same fix was applied todiscord/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
intfield onMigrationState,RollbackProgress,FailedMessageandChannelResultmust be0on a fresh instance, and everyboolmust beFalse. Test-only, nothing undersrc/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.shchecks 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.shsweeps a pull request for language that defers work without filing it. It runs in a new single-rungatesCI 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.shruns 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 amutationextra, so it is not installed in CI. -
scripts/check-deferral-fields.shis 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
rankonPATCH /servers/{id}/roles/{role_id}, and the Stoat backend discarded it: the upstreamroles_edithandler destructuresDataEditRolewith a rest pattern that does not bindrank. The request returned 200,hoistandiconin 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_messagesent 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:8765the 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_NATIVEskips 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-Afterand-Bucketare set unconditionally incrates/core/ratelimits/src/rocket.rsand 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) andmessaging(10 per 10s) are keyed by CHANNEL ID, so work spread across channels does not share a budget; onlyservers(5 per 10s) is shared. The table had no row forchannelsat all. - DCE writes PascalCase enum names,
GuildTextChatandGuildNews, 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 whatferry checkfound (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-runreports the plan and changes nothing.ferry retry, exposing a coroutine that had no user surface (cli.py).run_retry_failedhas been complete and tested since v1.x with no command able to reach it, so a user with failed messages instate.jsonhad no way to re-send them. Cheaper and narrower thanrepair: it contacts the server only to send, where repair spends a request per channel checking first.
Fixed
run_retry_failednever populated the token store (core/engine.py)._ensure_token_storewas called byrun_migrationandrun_rollbackand by nothing else, soconfig.token_storestayedNonefor the whole retry path.safe_sanitizeis an identity function with aNonestore, so a Stoat token appearing in an exception reachedstate.failed_messages, and through itreport.json, unredacted. Latent only because no command could reach the coroutine, whichferry retrychanges.- 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_messagetakes anidempotency_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 nowapply_channel_permissionsandapply_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.mdgains both commands, their options and their exit codes.known-limitations.mdgains 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 checknow 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 --jsonprints 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 checknow 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,mergeandarchiveruns asflatten, 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 checkcommand 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.
okmeans found and matching,warnmeans a category was renamed but its contents are intact,failmeans something is gone, andunverifiablemeans Ferry cannot answer and says why. Awarnon its own does not fail the command.
Changed
- Nothing existing changed behaviour.
--validate-afterstill does what it did, andferry validatestill inspects an export before a migration rather than checking one afterwards.
Notes
- What
ferry checkcannot 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 anokmeans "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 reportsunverifiablerather 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 archivegain the same line. --incrementalnow 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 mergeno 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 usingflatten, 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
--incrementalrun 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-checkcould fail with a traceback instead of reporting, on a machine where the system proxy configuration could not be read. It now reportsproxy-source: unreadableand 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,--incrementaland 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.jsonandstate.jsonare 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.jsonis 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 --jsoncould print output that a JSON parser rejects. The payload went through the module-level Rich console, which hassoft_wrap=Falseand 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 throughclick.echo, which does not wrap.probe --jsonis the only--jsonpath 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.ymlstructural tests intests/test_packaging.pynow assert that each guarded branch reaches itsexit 1, rather than only that the condition text is present. A condition is not an assertion: deleting theexit 1turns 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-plusexit 1lines. Verified by removing theexit 1from each of the four branches in turn and confirming the matching test fails. -
docs/architecture/andscripts/check-deferral-fields.share now gitignored, joining.claude/,CLAUDE.mdanddocs/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_markdownwarning disabled Start Migration, the only route to the migration screen, with no override anywhere in the interface. The same export migrated normally throughferry 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
mentionsarray 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 validateand 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 validatestill 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_PROXYandNO_PROXYwork 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_PROXYalways exempts a host. The system's own exception list is honoured as well, for a proxy the system supplied, which is howcurlbehaves: 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_PROXYandNO_PROXY, why the kill switch cannot fix a stale system proxy entry during export, why the GUI andferry tls-checkcan 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_autumnlet aClientConnectorCertificateErrorpropagate unwrapped, andstructure.py's role-icon handler catches it as anOSErrorand 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_tokensnow callsregister_secretfor 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 noaiohttp.webcode 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 theregister_secretcalls 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_COUNTis 0.
Fixed
-
auto-tag.ymlnow checks whether the current version is already tagged. It previously comparedpyproject.tomlatHEADagainstHEAD~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 reachedmainthat 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; atfetch-depth: 2it passed--no-tags, leaving the duplicate-tag guard with nothing to find. -
install.md,first-migration.mdandgui-walkthrough.mdsaid a browser opens automatically. The packaged Windows and macOS apps bundle a window toolkit, so NiceGUI setsshow=Falseand no browser is opened (issue #123). Apipxinstall does open a browser, becausepywebviewsits in the optionalnativeextra. The pages now cover the two routes separately and givehttp://localhost:8765as 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 forFerry.exeignoring--helpon versions before 2.12.0.cli-reference.mdandinstall.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
--versiongets its value. The existing test asserted that the output containsdiscord_ferry.__version__without pinning the source. Removing the explicit argument fromclick.version_optionmakes Click readimportlib.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 reloadscli.py.
Changed
bug_report.ymlasks for the log file at~/.discord-ferry/logs/ferry.log, added in 2.11.1. It previously asked forferry-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 reads0.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 mergeno 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.mdno longer claims Stoat'sIdempotency-Keyprotects 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
@everyonein 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 —MentionEveryoneis bit 37 andMentionRolesis 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.mdnow lists all 34 permission bits and states plainly that the authoritative source is stoatchat'sChannelPermissionenum, notdevelopers.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'sRevolt.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 atagskey 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 readsfeatures.limits.<new_user|default>.file_upload_size_limitsfrom 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 probecan no longer be aborted by a malformed instance response (migrator/probe.py). It parses JSON from a server it does not control, andrun_probewraps its four checks intry/finallywith noexcept— so a single unexpected type (sayfeatures.limits.defaultarriving as a string rather than an object) raised anAttributeErrorthat killed every remaining check and propagated to the caller. Truthy non-dicts were the dangerous shape, because thex or {}idiom does not catch them. Each hop of the parse is now type-guarded.
Changed
ferry probereports 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.specorrelease.yml). Theworkflow_dispatchtrigger added in 2.8.4 could never serve its stated purpose: GitHub only offers it from the default branch's copy of a workflow, andauto-tag.ymlpushes 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. Thereleasejob 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
.appcontains 119 symlinks;zip -rfollows 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 withdittoand verifies the extracted artifact (symlink count, size,codesign, bundled data files). It also gainedworkflow_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 raisedIndexErrorinto a "requires native mode" toast. It now goes throughapp.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 advertisesX-RateLimit-Reset-After: 10000(milliseconds) on a 429 — verified live againstcdn.stoatusercontent.com— but_retry_after_msnever read that header. A real Autumn 429 carries no bodyretry_afterand noRetry-After, so every one of them fell through to the 1000 ms default and retried into a bucket that was still shut. WithMAX_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 wheremigrator/api.pyread it and over-waited by 6×,uploader/autumn.pynever 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 capmigrator/api.pyalready 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 negativeretry_aftermadeasyncio.sleepreturn immediately and turned the retry loop hot; anX-RateLimit-Reset-Afterof3600000slept 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 catchNaN: every comparison against it is False, so it survivesmin/max, andasyncio.sleep(nan)then never returns — one such response would hang an upload forever. (time.sleep(nan)raisesValueError; asyncio's silence here is CPython #105331.) It needs no malice to arrive: Python'sjsonmodule parses bareNaN/Infinityby default, so{"retry_after": NaN}from a sloppy server reaches us as a float. Separately,boolis a subclass ofintin Python, so a JSONtruewas accepted as a 1 ms delay — beneath the clamp's floor, and so effectively no backoff.
Internal
.gitignorenow 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 sendsX-RateLimit-Reset-Afterin milliseconds — documented at developers.stoat.chat as "Milliseconds left until calls are replenished" — on every response including 429s. Ferry read it as seconds, somin(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_afterpath (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_afterasserted 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_secondsand documents its unit contract.discord/client.pycontains 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 bytest_discord_reset_after_header_is_seconds_not_milliseconds, which fails if that merge is ever attempted.Retry-Afterremains 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.ymldownloads the built artifacts intorelease-assets/and only afterwards runsuv 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.gzagainst 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.ymlcarried nomarkdown_extensionsblock, soadmonitionandpymdownx.tabbedwere 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.tocandtablesare 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 -drin both install and troubleshooting guides. Without-rthe 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.ymlcould ever have run. Both gated onif: env.<SECRET> != ''while defining that variable in the same step'senv:block; a step cannot read its own step-levelenvfrom its ownif:, 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 MBagainst 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 overmacos-14(arm64) andmacos-15-intel(x86_64), publishingFerry-macos-x86_64.zipalongside the existingFerry-macos-arm64.zip. Releases were previously arm64-only, so Intel Macs could not run Ferry at all.macos-15-intelis 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.ymlwhere that work belongs; note it also needs--options runtimeand 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.setuptools82.0.1 → 83.0.0 (MANIFEST.in exclusion bypass in sdist via NFC/NFD Unicode normalization collision on macOS APFS/HFS+; transitive viapyinstaller+pyinstaller-hooks-contrib,devextra) andpymdown-extensions10.21.3 → 11.0.1 (path traversal in theb64extension letting<img src>read files outsidebase_path; transitive viamkdocs-material,docsextra). 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. Thepymdown-extensionsfix is a major bump that Dependabot would not raise automatically;mkdocs-material9.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
.gitignorenow covers.claude-session-lock, which sat untracked next to the already-ignored.claude/directory and showed up in everygit status.
[2.7.0] - 2026-07-06
Added
- The seven internal settings are now user-configurable (closes #99).
ferry migrategains--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_configwith parse-timeChoice/IntRangevalidation 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_settingshelper (clamps stale/out-of-range disk-backed storage values, handlesui.number's float-or-None, falls back totexton unknown reaction modes). Migrate's--max-concurrent-requestsis unrelated to rollback's same-named delete-concurrency flag. - Official-service concurrency warning. Raising either concurrency value above its default
while targeting
api.stoat.chatprints a CLI warning / shows a GUI notify (informational, never blocks): the official rate limits make higher concurrency slower, not faster.
Fixed
max_concurrent_channels <= 0no longer deadlocks the message phase.migrator/messages.pybuiltasyncio.Semaphore(config.max_concurrent_channels)unguarded —Semaphore(0)admits no worker and hangs forever. Now clampedmax(x, 1), matching the existing defensive clamps forcheckpoint_intervalandmax_concurrent_requests. Locked by await_for-bounded regression test.
Documentation
- CLI reference: the "Internal defaults (not currently configurable)" section is gone — the
seven settings are documented as real
migrateflags.gui-walkthroughdocuments the 16-control Advanced Options panel.large-servers,pre-flight-checklist, andself-hosted-tipsregain 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 probecommand 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 filenamereport.jsoncorrected tomigration_report.json;export-blueprintoptions corrected (--outputis required;--nameexists); README gained badges and a section on the non-migration commands.
Internal
- Removed dead
_patch_checksumstest helper (tests/test_exporter_manager.py). It was defined but never called (everyTestVerifyDceChecksumtest inlines its ownpatch("importlib.resources.files")), and was broken anyway — it built a_Ctxcontext 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-socketio5.16.1 → 5.16.3 — binary-attachment accumulation DoS (GHSA-5w7q-77mv-v69f, patched 5.16.2).python-engineio4.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 persistedstate.warnings/RollbackFailure.error— honoringsafe_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 orstate.json. A new_ensure_token_storehelper wires the token store in bothrun_migrationandrun_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_cachehad 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-flightasyncio.Futureregistry 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 9avatar_cacheentries, re-uploading those avatars as untracked orphans on resume. It now saves on a 5s wall-clock throttle (mirroringmessages.py), bounding crash loss by time. - Repaired a dead test assertion (
tests/test_thread_strategy.py).test_merge_mode_separator_sentdefined a capture callback that aioresponses never invoked, so the thread separator payload was never actually asserted. Converted to the_capture_keysidiom 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_underlinecollapsed 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_timestamptreated 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_infotreated anyGuild - 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_REregex was removed. - A huge DCE stderr line no longer crashes the exporter (
exporter/runner.py)._read_stderrusedasync for, which raised an uncaughtValueErroron 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_nametruncated 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_STOATomitted 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'sMENTION_EVERYONEhas no StoatPermissionequivalent 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 toapp.storage.user, which NiceGUI persists to.nicegui/storage-user.jsonon 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 — thesecurity.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 readsapp.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). Inthread_strategy="merge", a message whose POST failed was recorded only as a warning, never aFailedMessage, while the high-water marker was written regardless — so on a later--incrementalrun the failed id sat below the marker and was skipped forever, unrecoverable byferry retry. The shipped #76 self-heal is now ported into the merge loop: a POST failure is recorded as aFailedMessage(in all modes); on--incrementala 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 viasafe_sanitizeso a token-bearing exception is never persisted tostate.json. Known limitation: the--incrementalre-attempt is the idempotency-safe primary recovery; recovering a partial-success multi-part merge message viaferry retrymay re-send already-delivered parts (theferry-merge-vsferry-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_categoriesbuilt 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 fullcategory_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 partialsource_messages_totalwhilefailed_countis carried cumulatively, socompute_fidelity_scorecould emit a negative Messages percentage (e.g. -60%) and depress the overall score. All five ratios are now clamped to[0, 1]via a_clamp01helper — a no-op for in-range inputs. The single helper change also fixesferry 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, incrementedconsecutive_failures, and ignored the server'sRetry-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 theRetry-After/X-RateLimit-Reset-Afterheaders (seconds) first, then the JSON bodyretry_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 distinctMigrationErrornaming 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_requestthat honours theRetry-Afterheader, 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).Consolerenders with markup enabled, so a Discord name containing Rich metacharacters (spam[/],[bold]news, an unbalanced]) interpolated into a progress line raisedrich.errors.MarkupError. Because_ProgressTracker.on_eventruns synchronously inside the engine'semit, that exception unwound into the engine and was re-raised as aMigrationError, 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, theferry statserror/warning previews) is now escaped via a new_safe()helper before markup interpolation; static format tags and integer/ID values are left live. A narrowexcept MarkupErrorguard 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). buildno longer aborts mid-build and orphans a server on a voice-channel failure (cli.py, S2/F5b). The blueprintbuildcommand POSTedchannel_type="Voice"with no error handling; a voice-create failure (Stoat "Bug #194") propagated andsys.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-breaksferry build --template gaming|community|education, whose shipped templates contain Voice channels.buildnow replays a blueprint's role hierarchy (cli.py, S4/F5d).BlueprintRole.ranksurvived 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 viaapi_edit_rolebut sentrankthrough 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-blueprintis unchanged (S3/F5c, decision A1). A Discord voice channel is still exported astype="Voice", consistent with the migration path and the shipped templates; S2's newbuildfallback 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 enteredemoji_map(no uploadable asset, or beyond the emoji cap) hit a branch with noelse— the reaction vanished with no counter or warning. It now increments a durablereactions_droppedcounter (on the per-channelChannelResult, folded into state; the retry/direct-state path writes state directly) and records a token-safeunmapped_emoji_reactionwarning. - Emoji discovery upgrades a stranded record on a better-asset re-encounter (
migrator/emoji.py). First-seen-wins discovery storedimage_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 enteredemoji_map, compounding the dropped-reaction loss). A new_record_emojihelper upgrades the stored record in-place when the storedimage_urlis 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 thanmax_emojiunique emoji are found, the kept subset was the first N bystr(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_EmojiRecordTypedDictkeeps the occurrence tallymypy --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 thencontinued without counting, so cap-skipped reactions were in neitherreactions_appliednorpending_reactions— invisible to the score. A durablereactions_cappedcounter 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 asreactions_applied + reactions_capped + reactions_dropped + len(pending_reactions)(nocompute_fidelity_scoresignature change). This also fixes thestats.pyNone-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 persistedMigrationStatecounters (reactions_capped,reactions_dropped) deserialise from oldstate.jsonas 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 retryno longer hangs or silently drops a still-failing message (migrator/messages.py,core/engine.py). On the retry path (_process_messagecalled withchannel_result=None), a send failure was caught, appended tostate.failed_messages, and the function returned without re-raising. The retry loopfor fm in state.failed_messagesthen (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 asretried(reported success) and dropped it._process_messagenow re-raises on failure only whenchannel_result is None(the parallel per-channel path keeps degrade-in-loop), andrun_retry_failediterates alist(...)snapshot — so the retry terminates, keeps the message failed withretry_countincremented, 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'sasyncio.CancelledErroras a result item, which the result loop logged as achannel_worker_failedwarning before emitting the phasecompletedevent — so the engine's clean-cancel handler never ran. The result loop now detectsCancelledErrorfirst (it is aBaseException, not anException) and re-raisesasyncio.CancelledErrorafter 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 ofbreak-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 reportedcompletedandcurrent_phaseadvanced pastmessages, so on--resumethe 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 withcurrent_phasestill"messages";--resumethen re-runs the crashed channel while already-completed channels skip viacompleted_channel_ids. All surfaced failure strings are sanitised once viasafe_sanitizeand 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 transientstate.migration_lock_marker(never persisted, re-acquired each run), andrun_serverfolds it into the description it already PATCHes — no extra API call._release_migration_lockclears the field. - A
--resumeafter a kill mid-SERVER no longer creates a duplicate server (migrator/structure.py).run_serversetstate.stoat_server_idfromapi_create_serverbut only the engine's post-phase save persisted it; a kill in that window left an on-disk state with no server id, so--resumere-entered the create branch and made a second server (orphaning the first with all its channels/roles). The id is now persisted withsave_stateimmediately after creation, so--resumetakes the reuse branch. run_rolesresume 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 persistedstate.roles_finalizedset now gates the attributes + permissions passes (the create pass still gates onpre_existing_role_idsto avoid duplicate creation); roles are marked finalized at the end of a completedrun_roles(regardless of the metadata-gated permissions pass), so a crash before the end re-runs both passes idempotently on resume.roles_finalizedis carried into--incrementalstate and back-compat-seeded fromrole_mapfor 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
200no longer aborts the migration with a raw exception (uploader/autumn.py). The success path didawait response.json()+result["id"], so a non-JSON200(reverse-proxy HTML, empty body) raised a rawaiohttp.ContentTypeError/KeyError, and an empty body would raiseTypeError. Because_resolve_role_icon(migrator/structure.py) caught onlyAutumnUploadError, such a raw error escaped its guard and aborted the entire migration for any server with custom role icons. The body is now parsed withjson(content_type=None)insidetry/except (aiohttp.ClientError, ValueError)and an explicitisinstance(result, dict) and "id" in resultcheck; every malformed200raisesAutumnUploadError(carrying only the tag — never the response body or token). --verify-uploadsnow actually detects a corrupt upload (uploader/autumn.py). On a present-and-mismatched serversize,upload_to_autumnpreviously popped the cache entry but still returned the bad id, whichupload_with_cacheimmediately 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 (raisesAutumnUploadError; never returned, never cached). The now-deadcacheparameter was removed fromupload_to_autumn.- A non-JSON
429no longer crashes the retry path, andRetry-Afteris honoured (uploader/autumn.py). The 429 branch didawait response.json()assuming a JSON body; an HTML429from a reverse proxy raisedContentTypeErrorthat bypassed backoff and (in the sticker/attachment loop) dropped the media. A new module-private_retry_after_mscomputes the backoff defensively: bodyretry_after(ms) →Retry-Afterheader (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 toexcept (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 passverify_size=config.verify_uploadsand register instate.autumn_uploadslike regular attachments. The embedmedia_idis additionally credited toreferenced_autumn_idson a successful send (but not added toautumn_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 rawmsg.content == "", but_build_contentand the caller append several optional bodies into the builtcontentbefore 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 builtcontent: it comparescontent.strip()against a reconstructed empty baseline (the timestamp prefix plus, for edited messages, the*(edited)*marker), so any pre-guard append path is preserved. Thenot autumn_ids and not stoat_embedsconditions 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_MARKERconstant so_build_contentand the guard stay byte-identical.
[2.6.5] - 2026-06-25
Fixed
- Thread
mergestrategy now honours the durable high-water marker (migrator/messages.py). Closes GitHub #78 (finding M2). The v2.6.3 incremental gate covered the defaultflattenstrategy (via_process_single_channel), butthread_strategy="merge"runs a separate function (_merge_threads) that re-POSTed the separator + every thread message into the parent channel on every--incrementalrun — idempotency-keyed so it never duplicated content, but wasted O(all-thread-messages) HTTP work each run._merge_threadsnow: skips the separator and already-copied messages on--incremental(gated onconfig.incremental), tracks the thread's max message id (over all messages incl. system/skip-type ones,isdigit()-guarded), and writes a per-threadchannel_high_watermarker at completion (every run, so a first full run sets it up). An unchanged merged thread therefore makes zero POSTs on an incremental run, matchingflatten; a thread with K new messages re-POSTs only those K (no re-sent separator).flatten,archive, and--resumebehaviour 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
--incrementalrun (#76). The durablechannel_high_watermark 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 resetfailed_messages, wiping its retry record. Now the incremental carry-over carriesfailed_messagesforward (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). Runningferry retrybefore--incrementalis no longer required — the v2.6.3 known-limitation note is resolved.--resumeis unaffected (it does not consultfailed_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 rawmsg.id, which can be non-numeric for some system messages; a prior run that crashed right after such a checkpoint then raisedValueError(swallowed byasyncio.gather→ the channel was silently skipped). The threshold now filters/normalizes non-numeric values to "no threshold" (copy, never crash) for both--incrementaland--resume, and the checkpoint write is guarded withisdigit(). - The incremental per-channel completion event now distinguishes re-attempted failures from new messages (
"{n} new, {m} already present, {k} retried"; theretriedclause 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 retrysemantics (no retry cap);retry_countis 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). --incrementalno longer re-streams and re-POSTs the entire history every run. The within-channel skip (messages.py) was gated solely onconfig.resume, but--incrementaland--resumeare 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 existingchannel_message_offsetsis 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 usesmax(high_water, carried_offset)so a crashed prior run degrades gracefully. Back-compat: an oldstate.jsonlacking 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 Truetautology 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 retrybefore--incremental. A message that failed to POST on a prior run (recorded infailed_messages) is below the channel's high-water mark, so a later--incrementalskips it and does not auto-retry it;failed_messagesis also reset per incremental run, so its retry record is lost. Retry such failures withferry retryagainst the priorstate.jsonbefore 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_reactionswas never cleared, so every surface computedreactions_total = reactions_applied + len(pending_reactions) = 2Nafter a fully successful run → a flat 50% reaction sub-score.run_reactionsnow consumespending_reactionsas 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_appliedre-incremented over the persisted value. The phases now checkpoint progress viasave_state(periodic, mirroring the messages phase) so a resumed phase processes only the remainder. A new persistedMigrationState.reaction_message_countskeeps the 20-reactions-per-message cap exact across a resume. ferry statsno longer reports 0% Messages fidelity on a normal run (stats.py,core/engine.py,state.py):summarize_stateused the incremental-onlyprior_messages_total(default 0) as the message denominator. A new persistedMigrationState.source_messages_total(set by the engine from the post-filter export total, on every run incl. resume) is now the shared denominator for bothferry statsand 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_urlintentionally excluded — it is an instance URL, not a credential). - Orchestrated export no longer clears the still-needed Stoat token.
_run_export'sfinallycleared both the Discord token and the Stoattoken, but the orchestrated flow immediately redirects to/validate, whose guard (and/migrate's) requiresstorage["token"]. The synchronousfinallyruns before the queued redirect loads, so the 1-Click flow bounced the user back to setup with an empty Stoat field. The exportfinallynow clears onlydiscord_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
_runhad nofinally, and offline mode (setup → /validate → /migrate, skipping /export) never reached the export cleanup — so the plaintext Stoat token persisted in.nicegui/storage-user.jsonafter completion or error (asecurity.mdviolation). The migration_rungained a terminalfinallythat clears both tokens on success AND error, for both offline and orchestrated modes. Safe for the in-flight migration:FerryConfigholds its own token copy (built before the task), the engine never readsapp.storage, and the only in-_runstorage read precedesrun_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
/migrateor lands on/validatebut 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). Previouslyrun_rolesderived its role set frommsg.author.roles, so a role only migrated if someone wearing it posted in an exported channel — empty/structural/staff roles silently vanished. When adiscord_tokenis supplied, role creation now sources from the union of export-derived roles and the full live Discord role list (already captured indiscord_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.RoleMetagainednameandcolor(normalised to hex at capture); a_role_from_metadataadapter synthesises aDCERolefor 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_roleslimit (default 200) that the wider role set can now reach. The live limit is read best-effort fromGET /(features.limits.global.server_roles, via a newapi_fetch_rootwrapper, 200 fallback); if the union exceeds it, the lowest-priority roles by(position, id)are dropped with a singlerole_limit_exceededwarning, and aTooManyRolescreate-time backstop stops gracefully rather than crashing the phase. - Reporter credit (
reporter.py): the## Native Fidelitysection 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@everyoneremain 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 andrun_serverminted a brand-new server (stranding the carried ID maps). - Server reuse (
migrator/structure.py):run_serverreuses the carriedstate.stoat_server_id(not only--server-id); a deleted prior server raises a clearMigrationErrorinstead of silently creating a new server. - Structure-phase idempotency (
run_roles/run_categories/run_channels): a capture-before-mutatepre_existing_*_idssnapshot 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-channelstruncation budget. - Exact category membership (
state.py,core/engine.py): a persistedchannel_categoriesmap 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 carriesforum_channel_members,forum_category_names,forum_index_message_ids,channel_message_counts,category_names,channel_categories, andnative_fidelity_counts, with a field-by-field audit comment._rebuild_forum_indexesPATCHes the existing pinned index with cumulative counts instead of posting duplicates. - Locked by
tests/test_incremental_structure.py(real-phase two-run tests). The priortests/test_delta_migration.pymocked 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_tokenis supplied, all source-verified againststoatchat/stoatchat@ a15a542: - Per-channel slowmode (
migrator/structure.py,migrator/api.py). Discordrate_limit_per_useris captured intoChannelMeta.slowmodeand applied via a newapi_edit_channelPATCH wrapper (routes through the existing rate-limited_api_request). Applied only when> 0, clamped to Stoat's0..=21600range with aslowmode_clampedwarning. Slowmode is a flat top-level PATCH field. - Voice channel user-limit (
migrator/structure.py). Discord voiceuser_limitis captured intoChannelMeta.user_limitand applied asvoice={"max_users": N}(nested). Discord's0(unlimited) maps to an omittedmax_users(Stoat rejects0). The voice PATCH is gated by acreated_as_voiceflag 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 theiconstag, and folded into the existing role attributes pass asDataEditRole.icon. Unicode-emoji-only role icons are not migratable and are skipped with a warning. The Autumn upload failure is caught asAutumnUploadErrorand 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 inMigrationState.native_fidelity_counts(serialized for resume) and surface asreport["native_fidelity"], a## Native Fidelitymarkdown 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-timeslowmode_skipped/user_limit_skipped/role_icon_skippedwarnings; 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). Whenvalidate_afteris enabled,run_migrationpersistscurrent_phase="validate_migration"— a terminal phase that is not inPHASE_ORDER. A subsequent--resumecalledPHASE_ORDER.index(state.current_phase)in_run_phaseswith no membership check, raisingValueError: 'validate_migration' is not in listbefore any phase ran, aborting the resume entirely. The resume guard now treats anycurrent_phaseoutsidePHASE_ORDERas "all runnable phases complete" (current_idx = len(PHASE_ORDER)), so resume correctly skips the finished pipeline instead of crashing. Locked bytest_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 calledPHASE_ORDER.index(event.phase)on every non-reportcompletedevent, but the engine emits aphase="validate_migration"completed event when post-migration validation passes — a value not inPHASE_ORDER, raisingValueErrorinside the event handler. Extracted a_phase_progress(phase)helper that returnsNonefor post-pipeline phases; the handler skips the progress-bar update for them. Locked bytest_phase_progress_pipeline_phasesandtest_phase_progress_post_pipeline_phase_is_none.
Security
dce_checksums.jsonis now bundled into the frozen binary (ferry.spec). The PyInstaller spec bundled onlytemplates/*.json, so the shipped binary lacked the pinned DCE hashes.exporter/manager._verify_dce_checksumreads them viaimportlib.resources, and itsexcept (FileNotFoundError, ModuleNotFoundError): returnclause 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")toall_datas. Locked bytest_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_sizebypass, 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. Theaioresponsesstream_writershim inconftest.pyis 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
probeCLI 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 avoicefield, not the discriminator), webhook availability (judged on EXECUTE, which is mounted only whenfeatures.webhooks_enabledis true — default off), and rate-limit header capture. Every entity created under the throwaway test server is torn down in afinallyblock (capture-id-before-raise), and the probe never constructs or writes aMigrationState. New modulemigrator/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(inviteblock), the markdown report (## Invite), and the post-migration checklist. Controlled by--create-invite/--no-create-invite(default on) and--invite-channel-id._select_invite_channelpicks 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'sappfield (bare code otherwise). Non-fatal on error (migration is already complete → warning, never raises), idempotent via aninvite_codeguard in both the engine caller and_generate_invite, and carried forward on incremental re-runs so resumes never re-mint. NewMigrationState.invite_code/invite_url(serialised, forward-compatible). Failure warnings are sanitised through the token store before reachingstate.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-safeapi_execute_webhookthat passestoken=Noneso the user'sx-session-tokenis 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
iconsAutumn size limit corrected to 2_500_000 (uploader/autumn.py). Source check against stoatchatRevolt.toml(features.limits.default.file_upload_size_limit) shows a flat 2.5 MB, not the2560 * 1024 = 2_621_440we 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
hoistflag (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 captureshoist(andposition) into a newRoleMetacarrier onDiscordMetadata, andrun_roles(migrator/structure.py) applies it. The former rank-only second pass became an attributes pass that foldsrankandhoistinto a singleapi_edit_rolecall per role, so hoist is applied even for roles with no colour and position 0 — and with no extra API calls versus before.mentionableis intentionally not migrated (no Stoat equivalent). Requiresdiscord_token; without it, hoist is skipped with one warning (hoist_skipped). Locked bytest_run_roles_applies_hoist_when_metadata_presentandtest_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_metadatanow capturesguild_descriptionandguild_nsfw, andrun_serverapplies them via the existingapi_edit_serverPATCH (description sent only when non-empty). Skipped with one warning (server_meta_skipped) when Discord metadata is unavailable. Locked bytest_run_server_applies_description_and_nsfw,test_run_server_omits_empty_description, andtest_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_categoriescall inrun_channelsnow sorts the categories array by each category's captured Discordposition(newDiscordMetadata.category_positions, populated only from type-4 GUILD_CATEGORY channels). Forum-derived categories — keyed incategory_mapby 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 bytest_run_channels_orders_categories_by_discord_positionandtest_run_channels_category_without_position_sorts_last.
Changed
DiscordChannelnow parses the channelpositionfield (discord/models.py,discord/client.py), previously discarded — needed for category ordering above.
Internal
- Extracted
_stoat_channel_type(int) -> strinmigrator/structure.pyas the single source of truth for the Discord-type → Stoat-type mapping (type 2 → Voice, else Text), replacing the inline match inrun_channels. - All three new metadata fields round-trip through
discord_metadata.jsonwith.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 intests/test_metadata.py. - The end-of-run server invite (S4) originally scoped alongside these features was dropped from this release:
api_create_invitebelongs 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 leftosx-arm64andlinux-arm64downloading unverified DCE binaries for years (phase 1, v2.1.5, pinned the missing ARM hashes; this phase closes the hole structurally). It now raisesDCENotFoundErrornaming the platform, refusing to use an unverified binary, and pointing at the escape hatches (--skip-dce-verifyCLI /skip_verify=TrueAPI) plus a request to file a bug to add the hash. Behavior change (latent breaking): if a future contributor adds a platform to_PLATFORM_MAPwithout 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 existingtest_dce_checksums_json_covers_all_supported_platformsregression guard keeps it that way. The missing-checksums-file path remains a skip (the file is bundled viaimportlib.resources; its absence is a packaging edge, not a platform-coverage gap). Locked by three tests intests/test_exporter_manager.py:test_dce_checksum_empty_hash_raises,test_dce_checksum_missing_version_raises, andtest_dce_checksum_unpinned_platform_raises(syntheticwin-arm64). Deferred since v2.1.5 (PR #42) for soak time, now satisfied.
[2.2.11] - 2026-06-07
Security
- Upgraded
aiohttp3.13.5 → 3.14.0 (closes Dependabot #22, #23). Two medium-severity advisories affect everyaiohttp < 3.14.0: GHSA-jg22-mg44-37j8 (deserialization of untrusted data) and GHSA-hg6j-4rv6-33pg (cross-origin redirect leaks per-request cookies). Ferry shipsaiohttpinside 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
starlette1.0.0 → 1.2.1 (closes Dependabot #24). GHSA-86qp-5c8j-p5mr (medium):starlette ≤ 1.0.0is missing Host-header validation, which poisonsrequest.url.pathand can bypass path-based security checks.starletteis a transitive dependency (vianiceguiandfastapi); both declare it without a version constraint, so the upgrade resolved cleanly to the latest1.2.1— past the1.0.1patch floor — with no other package churn inuv.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 madestream_writera required keyword-only argument ofClientResponse.__init__. aioresponses 0.7.8 builds its mock responses by calling that constructor directly without it, so under 3.14 every mocked HTTP call raisedTypeError: ClientResponse.__init__() missing 1 required keyword-only argument: 'stream_writer'ataioresponses/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), soconftest.pycarries an equivalent: asetdefault("stream_writer", Mock(output_size=0))wrapper aroundClientResponse.__init__, guarded by aninspect.signaturecheck so it is a no-op onaiohttp < 3.14and inert once aioresponses ships the fix. AREMOVE THISmarker 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.appendcallsites now sanitize messages through the token store (closes #47). v2.0.1 wiredSecureTokenStoreand added_safe_error/sanitize_for_displayhelpers, but a coverage audit during v2.2.0's ship review found fivestate.errors.appendcallsites that interpolated raw exceptionrepr()s without going through any sanitizer:migrator/emoji.py:296,migrator/reactions.py:112,migrator/pins.py:83,migrator/messages.py:382, andcore/engine.py:599. If a Stoat/Discord/Autumn aiohttp exception'srepr()ever contained a token value (token-in-URL leak from a misconfigured HTTP library, 401 body echo, etc.) it landed instate.jsonunredacted — visible viacat state.json,ferry stats'slast_error80-char preview (added in v2.2.3), andreporter.generate_markdown_report's### Errorssection. The exposure pre-datedferry 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_errorfrommigrator/messages.py(private, used at one site) tocore/security.py:safe_sanitize(token_store, text)— aNone-tolerant wrapper aroundSecureTokenStore.sanitizeso 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 throughsafe_sanitize(config.token_store, ...). Thecore/engine.pysite is the trickiest: the samestr(e)interpolation also flowed into the wrappedMigrationError, theMigrationEvent.message, and the eventdetail'serror— all four are now sanitized via a singlesafe_exclocal, 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), andtest_safe_sanitize_at_state_errors_call_site(end-to-end: synthetic token in aRuntimeError's URL → masked in the resultingstate.errors[-1]["message"]). - Acceptance:
grep -n "state.errors.append" src/shows all five sites; manual audit confirms each goes throughsafe_sanitize(4 migrator sites directly in the"message"value, 1 engine site via the sharedsafe_exclocal). 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
reporterfidelity score: reactions sub-score no longer inflates in partial state (closes #46).generate_report(reporter.py:120) andgenerate_markdown_report(reporter.py:351) both passedreactions_total=len(state.pending_reactions)tocompute_fidelity_score— a denominator that's wrong in both terminal states. Completed migration:pending_reactionsis drained → denominator is 0 → the function'sif reactions_total else 1.0short-circuit silently returns 100%, hiding any reactions that failed. Partial migration (e.g.reactions_applied=10,pending_reactions=[5 items]): denominator is 5 → ratio is10/5 = 200%, inflating the overall score by up to 10 percentage points (the reactions weight). Both callsites now use the formulastate.reactions_applied + len(state.pending_reactions), matching whatstats.summarize_state(added in v2.2.3) already does — soferry statsandmigration_report.json/.mdagree on the reactions sub-score for the same state. Locked bytest_fidelity_reactions_partial_state_not_inflated(asserts 60-70% for the 10/15 case) andtest_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 unrelatedstate.errorssanitization 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 type11, so the previousactual_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 amanifest_tc_id_to_discord_idmap through the diff so the lookup targets the correct text-channel parent. Current fixture (Cool ThreadvsBug Report) doesn't trigger the collision; the newtest_diff_distinguishes_thread_from_forum_post_with_same_nameregression test injects a colliding foreign forum post named "Cool Thread" insidefeedback-forumand asserts the manifest'sCool Threadthread undergeneralis 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 coveredprovision_test_server.py(yes — that's the human-only CLI) ortests/provisioning/test_*.py(no — those are hermetic viaaioresponsesand 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_channeland_parse_message's reference block — the four fields exposed by the new captured fixtures. The same pattern remained in seven other parsing sites (_parse_exportexportedAt,_parse_guildiconUrl,_parse_authordiscriminator/nickname/avatarUrl, the inlineDCEEmojiconstruction in_parse_reactionforid/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 thestr(raw.get(K) or default)pattern, preserving the existing defaults (""everywhere exceptdiscriminator'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 emitnullfor bothidandimageUrl).
[2.2.6] - 2026-05-18
Tests
- Real DCE 2.47.1 fixtures replace synthetic ones (closes issue #35). Three real
DiscordChatExporter exportguildcaptures from a Discord guild provisioned viatests/provisioning/provision_test_server.pynow back the parser test contract:Discord Ferry Test - general [1506019498094891120].json(10 user messages + 1ThreadCreated, including the 3-inline + 2-non-inline embed),Discord Ferry Test - general - Cool Thread [1506019505778987190].json(thread starter + reply), andDiscord Ferry Test - feedback-forum - Bug Report [1506019530294562938].json(forum post body). The syntheticTest Server - general - Cool Thread [888888888888888888].jsonandTest Server - Feedback Forum - Bug Report [999999999999999999].jsonare removed.test_parser.pyupdated for the new filenames, theparse_export_directorycount assertion (5 → 6 valid DCE JSONs), and the Discord-normalizedfeedback-forumparent name intest_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 (0and11). The parser previously calledint(raw["type"])directly, whichValueErrors on the new format — discovered when the real DCE captures used to replacetests/fixtures/synthetics failed to parse. Fixed with a_DCE_CHANNEL_TYPE_TO_INTmapping covering the 11 channel enum names emitted by DCE'sChannelKindand a_coerce_channel_typehelper 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,Noneinput, and unknown-stringValueError. - Null JSON fields collapse to
""instead of the truthy string"None". DCE emitsnullforcategoryId/categoryon top-level channels and fortopicon threads — and forreference.messageIdonThreadCreatedsystem messages. The previousstr(raw.get(K, ""))pattern returned"None"for these (becausedict.getonly 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 inmigrator/structure.py:496(if cat_id and ...),review.py:73(if export.channel.category_id: ...), andcli.py/gui.pychannel-scan summaries — potentially leading to phantom-category creation. The synthetic fixtures hid the bug by using""empty strings; the real captures surfaced it. Fixed withstr(raw.get(K) or "")in_parse_channel(3 fields) and_parse_message's reference block (3 fields). Locked bytest_null_json_fields_collapse_to_empty_stringagainst 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 withprovision/teardown/verifysubcommands that hits Discord's REST API using a Bot token fromDISCORD_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→ CLIprovision_test_server.py). Reconciler uses a sealedDiffOpTdiscriminated union withassert_neverexhaustiveness; 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 bytest_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 usesurl.removeprefix(DISCORD_API_BASE)to keep only the relative path. ProvisioningPermissionErrorexits 2 across all CLI subcommands. Previously a 403 fell through to the bareProvisioningErrorhandler and exited 1 (drift) instead of 2 (couldn't-determine), violating the documented 3-way exit-code contract intests/provisioning/README.md. Permission errors are now handled explicitly alongside auth errors.reconcile_teardownno longer swallows auth/permission failures intoskipped_count. Previously a token revocation mid-teardown reported "deleted 0, skipped N" with exit 0, masking the failure. The function now re-raisesProvisioningAuthErrorandProvisioningPermissionErrorso the CLI can surface them as exit 2. OtherProvisioningErrors (5xx, network) are still treated as transient and counted inskipped_count. The CLI now also prints a warning whenskipped_count > 0so 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 inpyproject.tomlensurestests/provisioning/is excluded from built wheels (verified: 0 entries undertests/in the built.whl); (b) PEP 561src/discord_ferry/py.typedmarker so downstreammypy tests/can honor the package's types instead of treating it as untyped; (c)tests/provisioning/__init__.py+_bot_api.pyexposing aProvisioningErrorhierarchy that deliberately does NOT inherit fromFerryError— 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-pythonbumped from>=3.10to>=3.11, the 3.10 trove classifier removed, rufftarget-versionbumped topy311, and mypypython_versionbumped to3.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 on2.2.3or upgrade their interpreter. The ruff bump surfaced 12 modernization opportunities insrc/, all auto-applied:datetime.now(timezone.utc)→datetime.now(UTC)(PEP 615 alias added in 3.11), andexcept 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 readsstate.json+message_map.jsonfrom 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 viareporter.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 (whenstate.rollback_progressis set), and a[DRY-RUN]badge in the title (whenstate.is_dry_runis 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_scoreto 100%, the sub-score renders asn/ainstead. Theoverallscore still uses the function's arithmetic unchanged ("no embeds to migrate = no embed loss" is a defensible interpretation). reactions_totalderivation:MigrationStatelacks areactions_totalfield, sosummarize_statederives it asreactions_applied + len(pending_reactions)— yielding correct ratios in both completed and partial states. Note:reporter.generate_reportuses the existinglen(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) sostats.pyandreporter.pyshare 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 thatstate.errors.appendcallsites inmigrator/emoji.py:296,migrator/reactions.py:112,migrator/pins.py:83,migrator/messages.py:382, andcore/engine.py:599do not wrap their messages with_safe_error/config.token_store.sanitize— meaning a Stoat/Discord/Autumn exception whoserepr()happens to contain a token (e.g. token-in-URL leak from a misconfigured HTTP library) would land instate.jsonunredacted. This pre-existing exposure also affectsreporter.generate_markdown_report(which rendersstate.errorsverbatim) and on-disk inspection ofstate.jsonitself. The newferry statscommand surfaces these errors via a truncated 80-char preview, making the exposure more discoverable but not creating new ingress. Follow-up: #46 will wrap allstate.errors.appendcallsites in the migrator with_safe_error. The fix belongs in the engine, not in stats consumers — stats was deliberately designed withoutFerryConfig/token_storeaccess. Until then, treatferry-output/state.jsonas 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
_heartbeattask alongside the existing stdout/stderr drain that emitsstatus="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.statusnow recognizes"heartbeat"as a value; existing consumers that don't recognize it degrade gracefully (the GUI'son_export_eventalready 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.pyinvokes the real DCE 2.47.1--helpand asserts every flag_build_dce_command()passes is still present in DCE's output; newtests/test_dce_output_replay.pyreplays the captured fixture throughparse_dce_lineand asserts the typed result for each non-comment line. Both run in a dedicatedcontract-testCI job onubuntu-latest(Python 3.12, .NET 8 preinstalled onubuntu-24.04), with the DCE binary cached across runs byDCE_VERSION. WhenDCE_VERSIONbumps 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.totalsemantics 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 fractioncurrent / totalis unchanged in shape (still 0-1) but its meaning is "fraction of channels completed" instead of "fraction of current channel completed." Any external consumer ofMigrationEventreading 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.1source -- DCE uses Spectre.Console'sFallbackProgressRendererwhen 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, andRawfallthrough for unknown lines. Unknown lines now surface to the GUI as[dce] <line>instead of being lost tologger.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 withCREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUPon Windows. - Stderr drain task leaked on cancel path: cancelling an export mid-stream left the
_read_stderrtask running until garbage collection. Now cleaned up viatry/finallyon every exit path. asyncio.LimitOverrunErrorwould 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_EVENTto the DCE process group (DCE has aCancelKeyPresshandler), with a 3-second graceful-shutdown window before falling back to hard kill. POSIX behavior unchanged (already usedSIGTERM). 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-openon Windows (sys.platform == "win32"falls into the non-darwin branch), which doesn't exist — the click silently failed with aui.notifytoast. Replaced with a three-branch platform check usingos.startfile(path)on Windows. Bundled audit confirmedxdg-openwas the only Windows-incompat bug insrc/(full grep results indocs/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 loudDCENotFoundError). New schema-validation tests intests/test_exporter_manager.py::TestDceChecksumsJsonguard against future regressions: any new platform added to_PLATFORM_MAPwithout a corresponding hash now fails CI.
[2.1.4] - 2026-05-16
Fixed
- Embed fields with
isInline: truenow 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:259writesisInline). Re-running migration on a server with bot-posted embeds will produce visibly cleaner output. The accompanying parser-audit appendix (see specdocs/superpowers/specs/2026-05-15-issue-36-isinline-and-parser-audit-design.md) confirmed this was the only key-typo intransforms.pyanddce_parser.py.
[2.1.3] - 2026-05-14
Fixed
- GITHUB_TOKEN cascade gap closed (Option C):
auto-tag.ymlnow uses a GitHub App installation token (minted viaactions/create-github-app-token@v3) instead of the defaultGITHUB_TOKENfor 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 manualgit push --delete origin v<tag>+ re-push protocol to firerelease.yml. After this fix,v*.*.*tag pushes fromauto-tag.ymlfirerelease.ymlautomatically. Credentials live in the newauto-tagGitHub Environment (AUTO_TAG_APP_CLIENT_IDvariable +AUTO_TAG_APP_PRIVATE_KEYsecret). The App (Ferry Auto-Tag Bot, owned bynordscope-fi, installed on this repo only) hasContents: Read and writepermission — minimum scope. No user-visible code changes.
[2.1.2] - 2026-05-14
Fixed
- CI hygiene correction —
upload-artifactanddownload-artifactwere still on Node 20 in v2.1.1: the v2.1.1 pinsactions/upload-artifact@v5andactions/download-artifact@v6were 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'sruns.using:field is authoritative — forupload-artifactthat flipped fromnode20tonode24at v6.0.0; fordownload-artifactat v7.0.0. GitHub's release.yml run for v2.1.1 surfaced this with aNode.js 20 actions are deprecatedannotation. 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 againstaction.ymland 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 indocs.yml),softprops/action-gh-release@v2 → @v3.pypa/gh-action-pypi-publishunchanged (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. Readsstate.jsonfor the entity IDs to delete. Idempotent re-runs: a 404 response is treated as "already deleted" via the newexpected_404_ok=Truepath on_api_request. Supports partial-rollback resume viastate.rollback_progress.rolled_back_ids(a new set field onMigrationState); 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 fromstate.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/servers5/10s bucket. Acquires the same[FERRY_LOCK:...]server-description marker asrun_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..."(beforedetect_dotnet()),"Launching DiscordChatExporter..."(before the subprocess spawn), and"DiscordChatExporter started — enumerating channels..."(insiderun_dce_exportimmediately 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.chatis NXDOMAIN — canonical URL ishttps://stoat.chat/app. Fixed in README (#15, thanks @FedeltaMedia) and swept the remaining 3 references indocs/index.mdanddocs/getting-started/setup-stoat.md.
[2.0.1] - 2026-03-19
Fixes
- Token sanitization wired (S1):
SecureTokenStorenow created in engine and used at error output boundaries — token values stripped fromstate.errorsand 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_messageinstead 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):
SecureTokenStorefor token masking,repr=Falseon 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.gatherwithChannelResultaccumulators - Adaptive rate limiting (S9): 429-frequency optimization with rolling window and auto-adjusting delay multiplier
Features
- Thread strategy (S7):
--thread-strategyflag with flatten/merge/archive modes - Message splitting (S3): Messages >2000 chars split with
[continued K/N]markers instead of truncation - Delta migration (S19):
--incrementalflag 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_idsset replaces fragile snowflake ordering - Emoji collisions (S6): Duplicate sanitized names get
_2,_3suffixes - 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
--forceoverride - 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-uploadsfor post-upload size check - Forum index rebuild (S15): Index built during REPORT phase with actual migration data
- Orphan detection (S16):
--cleanup-orphansflag detects unreferenced Autumn uploads - Code signing (S20): CI pipeline prepared for macOS/Windows binary signing
Breaking Changes
- State format v2:
completed_channel_idsreplaceslast_completed_channel/last_completed_message message_mapstored in separatemessage_map.jsonfile- 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.mdlisting 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-indexchannel 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_editednow 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.mdgenerated 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
FailedMessageobjects with Discord ID, error, and content preview. Newrun_retry_failed()re-processes failures using single-scan strategy. - Configurable reaction strategy: New
reaction_modeconfig —"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=Truegrouped 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_messagesconfig (default 0) excludes threads below the threshold. Filtered threads logged as warnings. - Post-migration validation: Optional
validate_afterphase compares Stoat server channel/role counts against state maps viaapi_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_intervalconfig 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.mddocumenting 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"tostatus="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}/categoriesandPATCH /servers/{id}/categories/{id}with correctPATCH /servers/{id}using the server'scategoriesarray 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}/emojiswith correctPUT /custom/emoji/{autumn_id}usingparentobject ({"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
maxLengthconstraint. - Message nonce deprecated: Replaced
noncebody field withIdempotency-KeyHTTP 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) andsanitize_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_headerssupport:_api_request()now accepts optional extra HTTP headers (used forIdempotency-Key).- 14 new tests: sanitize helpers (12), masquerade truncation (1), role name truncation (1) — 440 total passing.
Changed
api_create_emoji()signature: Now takesemoji_id(Autumn file ID),name, andserver_idinstead ofnameandparent(Autumn ID).api_send_message()signature:nonceparameter replaced withidempotency_key.api_create_category()andapi_edit_category()removed: Replaced byapi_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.pybuild command: Updated to useapi_upsert_categories()with client-generated IDs.- Documentation: Updated
stoat-api-notes.mdwith 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-blueprintconverts a DCE export directory into a reusable JSON blueprint.ferry buildcreates 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.jsonstores translated permissions and NSFW flags alongsidestate.jsonfor 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.jsonfor 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 buildNameError 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/-yto 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_onlyparsing mode:parse_export_directory(metadata_only=True)skips message loading for fast validation- CLI orchestrated mode:
--discord-token+--discord-serverflags for automatic export, mutual exclusion with--export-dir - GUI orchestrated mode: Mode toggle (Orchestrated / Offline), Discord credential inputs, ToS checkbox,
/exportpage 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.0for 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 withmetadata_only=True - Runner lifecycle events: Engine owns started/completed events; runner only emits progress
- Discord token security: GUI clears token from persistent storage in
finallyblock (covers failure paths) validate_discord_token: Now catchesaiohttp.ClientErrorfor 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 optionalwebviewimport
[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-limitoption 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.warningsandstate.errorsdicts 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
--mediaexports 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_threadsGUI checkbox: Exposed in Advanced Options alongside existing skip togglesattachments_uploadedcounter: Accurate attachment count in state and reports (replaces deduplicated cache length)- GitHub Actions docs workflow:
docs.ymlbuilds 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_attiming: Report timestamp now set beforegenerate_report()runs, giving correct durationsilentmessages: All migrated messages sent withsilent: trueto prevent notification spam- Missing skip types:
CallandChannelIconChangemessages now skipped during import ConnectionErrorshadowing: Renamed toStoatConnectionErrorto avoid shadowing Python builtin- GUI resume race condition: Migration start gated behind
asyncio.Eventuntil 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) andpyproject.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.chattoapi.stoat.chatin 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-runCLI flag and GUI checkbox run all phases without API calls, producing syntheticdry-*IDs for validation - Configurable server limits:
--max-channelsand--max-emojiCLI 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.jsonand 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.pycovering 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_messageassignment 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
ClientSessionreplaces per-phase sessions for better connection reuse - Removed
stoat-pydependency: Project already uses custom raw aiohttp API layer; the SDK was unused weight - Removed
aiofilesdependency: Listed but never imported anywhere in the codebase - Engine refactor: Extracted
_run_phasesfromrun_migrationfor readability; fixed silent return-value bugs
[0.8.1] — 2026-02-27
Fixed
- skip_threads flag wired to nothing:
FerryConfig.skip_threadsnow filters thread/forum exports in both the channels and messages phases - GuildMemberJoin and ThreadCreated not skipped: Added to
_SKIP_TYPESso 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.jsonfilename - Animated emoji warning missing: Emits a warning when uploading animated emoji (animation is lost on Stoat)
- Validate-phase emoji count undercount:
validate_exportnow 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_eventandcancel_eventonFerryConfig, 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 withmigrateandvalidatesubcommands, Rich progress display with phase status icons, export summary table, ETA estimate,.envsupport viapython-dotenv, environment variable fallbacks (STOAT_URL,STOAT_TOKEN),--resume/--skip-*/--rate-limitflags,MigrationErrorandKeyboardInterrupthandling with exit codes - 14 CLI tests using Click's
CliRunnerwith 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 viastate.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 Britishcolour, 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_namecollision 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, andupload_with_cachehelper backed bystate.upload_cache - CONNECT phase (
migrator/connect.py): discovers Autumn URL viaGET /, verifies auth token viaGET /users/@me, storesautumn_urlin migration state - Engine default phases (
core/engine.py):_DEFAULT_PHASESdict 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): producesmigration_report.jsonper brief §12 with summary counts, ID maps, timing, warnings, and errors - Event callback (
core/events.py):EventCallbacktype 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