Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog.
[Unreleased]
[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. See ADR-014.
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 now folds colour + rank into a singleapi_edit_rolePATCH. 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