ANetBBS Changelog — Beta History (pre-v1.0.0)

Archived history of every internal beta build number (v1.0a1.1 through
v1.0b2.239) from before ANetBBS's v1.0.0 full release in August 2026.
Preserved as-is — a real record of what changed at each step during
development — but split out of the main CHANGELOG.md
once it passed 6,000 lines, to keep that file scannable for the
v1.0.0-onward history that actually matters day to day. Newest-first,
same as the main changelog.

v1.0b2.239 — Sysop message delete button; [ANSI] subject auto-tag (July 2026)

Sysop delete button for echomail messages. Found live, during the ANSI-art rendering incident above: there was no way to remove a single message short of hand-written SQL against the live database — a one-off, error-prone, unrepeatable operation exactly the kind a real UI should handle. New admin-only "Delete" button on the message-read view (anetbbs/web/echomail.py's new delete_message() route). Admin-only by design: echomail is shared FTN network content, not a personal post — unlike board post deletion (author OR admin OR moderator), a regular user has no standing to remove network content even if they authored it. Hard-deletes the message and cleans up every table that references it: BinkPHoldQueue (the not-yet-sent outbound queue — the exact cleanup step that previously required hand SQL), EchomailReadStatus, and QWKNodeLastSent's dangling high-water-mark pointer (the subscription row itself survives; only its pointer is cleared). Can only remove the local copy and anything still pending delivery — it cannot recall a copy already delivered to a peer. 3 new tests.

[ANSI] subject auto-tag. Requested after the same incident: a message with real CP437/ANSI art content should get an [ANSI] subject prefix, so a reader on a non-ANSI-capable client (or just skimming a message list) knows to expect colored/box-drawing content before opening it — matches a real example from a peer sysop whose Synchronet BBS already tags subjects this way. New shared helper maybe_tag_ansi_subject() (anetbbs/models.py, alongside the existing format_tagline_append/get_active_taglines shared formatting helpers) — triggers on a real ANSI escape sequence specifically (not just CP437 box-drawing characters alone, which show up constantly in ordinary quoted replies/taglines and don't warrant a warning), is idempotent (never double-tags an already-tagged subject, case-insensitively), and truncation-safe. Wired into all 9 places a message subject gets set: the 3 inbound import paths (poller.py, binkp_server.py, qwk_hub_ftp.py — this is what actually matters for a peer-sent message like the example above) and the 6 local compose surfaces (web echomail compose + netmail_compose, web netmail.py, web telegram.py, terminal bbs_ui.py's two compose paths, PETSCII's _echo_compose) — this project's echomail composers have repeatedly needed a fix mirrored across every sibling path, so this went in as one shared helper called identically everywhere rather than nine copies of inline logic. 9 new tests, including an end-to-end check reproducing the exact reported scenario (a Codefenix-style inbound message with ANSI content ending up correctly tagged).

v1.0b2.238 — Same-day fix: v237's ANSI art fix wasn't enough — the strip-line-breaks heuristic itself was wrong (July 2026)

Deploying v237, the art was still not displaying correctly — a side-by-side comparison against the same message viewed on a real Synchronet BBS (correct) versus ANetBBS (still scrambled, though no longer collapsed to nothing) made the remaining problem obvious.

v237 fixed real data loss (a leftover \r causing rows to overwrite each other), but didn't question whether line breaks should be stripped in the first place. The "flat block art" fast path in render_msg.py and anedit.py strips line breaks so the VT emulator's own 80-column auto-wrap owns row layout — correct only when a source line is actually wider than 80 columns (the one case where an explicit break conflicts with the emulator's own wrap: the line wraps once naturally, then its own trailing break advances the row again). The condition that decided whether to strip never checked this — it fired for any flat block art with no cursor-positioning, full stop. groot.ans's widest real line is 77 columns, comfortably under 80, so it never needed stripping at all; every line was its own intentional row. Stripping anyway didn't lose characters (that was v237's fix), but glued multiple short source lines onto shared auto-wrapped rows, scrambling the whole picture. Keeping the real \r\n breaks lets the VT emulator's own already-correct \r/\n handling lay out one row per source line, exactly as authored.

Fixed by checking each line's actual visible width (escape sequences excluded) against the 80-column limit before deciding to strip, in both render_msg.py's two render functions and anedit.py's terminal equivalent — replacing anedit.py's old _avg_line > 70 gate (a body-wide raw average including escape-code bytes, an equally imprecise proxy) with the same precise per-line check. Confirmed against the real live message: correct layout now produces 69–70 rows (one per source line, matching the original ~140-line file after the descriptive header), versus 55 wrong ones from the v237-only fix. 2 tests rewritten to cover both the "line fits, breaks are kept" and "line overflows, breaks are stripped without losing characters" cases explicitly.

v1.0b2.237 — Flat CP437/ANSI art collapsed to almost nothing in web UI and terminal (July 2026)

Found live: a sysop-composed ~140-line CP437/ANSI art piece (pure SGR color codes, no cursor-positioning) rendered as just a couple of stray colored fragments in the web UI — a ~7500-character message produced only 558 characters of HTML with a single line break. It had already gone out to one downstream BinkP node before this was caught; the other four still-pending queue entries were removed to stop further propagation.

Root cause: both anetbbs/web/render_msg.py (web UI) and anetbbs/features/anedit.py's render_message_body_lines() (terminal ANView) have a "flat block art" fast path that strips line-break characters entirely, letting the VT emulator's own 80-column auto-wrap own every row advance instead of trusting explicit breaks — but both only ever stripped \n, leaving every \r from CRLF-terminated art (the overwhelming majority of real-world FTN/DOS ANSI art) behind in the text. The shared VT state machine (anetbbs/features/ansi_html.py) treats a bare \r as "column := 0" without advancing the row, exactly matching real terminal behavior — so each subsequent source line silently overwrote the previous one at the same row instead of moving to a new one. A many-line piece of art collapsed onto a handful of rows, each repeatedly overwritten by whatever line wrote to it last.

Fixed in both places identically: strip \r alongside \n rather than just \n — same original intent, now actually implemented for CRLF content. Confirmed against the real live message before and after the fix (558 → 29,314 rendered HTML characters, 7 → 651 color spans, 1 → 50 line breaks). 3 new tests, each verified to fail without the fix and pass with it.

Also root-caused and confirmed via direct diagnosis that the message's stored content was never corrupted — the real downstream Synchronet BBS that already received it rendered the art perfectly, confirming this was purely a rendering-side bug on ANetBBS's own web/terminal display, not a data or transmission issue.

Also improved the network-join approval email (approve_join_request() in anetbbs/web/hub_admin.py), found while manually resolving the above incident's stuck outbound queue entries: it gave a new node's FTN address and BinkP session password, but never the BinkP port, never mentioned that a fresh node starts with zero echo/file area subscriptions until they send an AreaFix/FileFix request (or the exact syntax to do so), and buried the auto-assigned node number inside the zone:net/node address string instead of calling it out plainly. Now includes the port, an explicit "Assigned node number" line, and — when the hub's own address is known — ready-to-use AreaFix/FileFix netmail instructions with the correct network-specific password. 1 new test.

Found while chasing down a handful of small /tmp cleanup items during a resource/memory check: leftover anetbbs_*_synchronet_compat.js temp files (write_compat_script() in anetbbs/games/synchronet_compat.py) that were never getting cleaned up.

Root cause: play_door_game_telnet()'s polling loop (bridging a Synchronet-JS door's PTY to a telnet/SSH/rlogin session) only ended when the user explicitly aborted (Ctrl+]q) or the door process exited on its own. It never checked whether the user's connection had dropped while the door was still running. door_dos (DOSBox) games are protected by their TCP bridge's own idle-timeout, but Node-based Synchronet-JS doors have no way to learn the user is gone — so a dropped connection just left the door (and its temp compat script) running orphaned until the door happened to exit by itself, which for many doors waiting on stdin, never happens.

Fixed: the loop now also checks in_task.done() — the input-reading task already detects a dropped connection immediately (a read returning empty/raising), it just wasn't being watched. 1 new test, verified both that it passes with the fix and that it correctly times out and fails without it (temporarily reverted to confirm before shipping).

Separately noted but not addressed here: the loop's own printed message to the user ("60s of zero activity will auto-abort the door") isn't actually enforced for this game-type family — only door_dos has a real idle-timeout. Worth a follow-up if a stuck-but-still-connected door turns out to be a real problem in practice.

v1.0b2.235 — Same-day fix: MRC verification check was probing with the wrong request shape entirely (July 2026)

The v1.0b2.234 retry fix didn't actually fix anything: the bridge's own access log showed all 5 retries getting genuine 101 Switching Protocols responses, while the script still reported "not answering" every single time. Root cause: a real WebSocket-upgrade request, once accepted, leaves curl holding an open duplexed connection with nothing to close it — curl just sits until --max-time kills it, so its process exit code comes back as a timeout on every success, not just failures. The check was reading that exit code instead of the status curl actually received. Fixed by dropping the Upgrade headers entirely (a full handshake was never actually needed — any HTTP response at all proves the bridge's HTTP layer is alive) and reading the captured %{http_code} directly, the same technique the already-reliable /healthz probe uses. Verified locally against a minimal test server before shipping this time, given the same check had now been wrong twice in a row.

v1.0b2.234 — Same-day fix: MRC verification check raced the bridge's own startup (July 2026)

Caught immediately on deploying v1.0b2.233 live: right after a restart, the new MRC connectivity check warned the bridge wasn't answering — but a manual re-probe seconds later got a clean 101 Switching Protocols. systemctl is-active only means the process started, not that it's finished binding its listen socket yet; a one-shot probe run immediately after the restart loop raced that startup window. Now retries up to 5 times over 10 seconds before warning, matching the existing /healthz probe's own reasoning for why it already retries.

v1.0b2.233 — Same-day fix: MRC verification check (v1.0b2.232) trusted a stale .env flag (July 2026)

Caught immediately after shipping v1.0b2.232: the new MRC connectivity check in update.sh gated entirely on .env's MRC_BRIDGE_ENABLED flag — which, on a real long-lived install, had gone stale at false while anetbbs-mrc-bridge.service was demonstrably active and serving real traffic. The check silently skipped itself on exactly the install where it mattered most. Now gates on MRC_BRIDGE_ENABLED == true OR the service actually being active (systemctl is-active), whichever says yes.

v1.0b2.232 — Removed redundant/broken MRC bridges; nginx MRC-proxy verification on install/update (July 2026)

Found while investigating a resource check: anetbbs-mrc-bridge-primary.service had been crash-looping every 3 seconds for 10+ days (systemd restart counter over 297,000) — its WorkingDirectory pointed at a directory from an old, pre-reorganization layout that no longer existed. Investigation showed it was never needed: the already-working default MRC bridge already connects to the same network (mrc.bottomlessabyss.net), and two other bridges (hub.uk.erb.pw, hub.ca.erb.pw, plus a third half-migrated na-multi.relaychat.net config found along the way) were confirmed no longer wanted. All three redundant/broken bridges — services, nginx /mrcws-uk + /mrcws-ca locations, and their config files — removed; Bottomless Abyss remains the sole MRC network ANetBBS bridges to. The web MRC client's server picker (mrc/web/index.html and anetbbs/templates/mrc/index.html — two near-duplicate copies, kept in sync) is simplified to match: the now-single-option dropdown is removed entirely rather than left as pointless UI.

Root-caused a separate, longstanding "web MRC doesn't work for some new sysops" report (terminal MRC over SSH/telnet was always fine — a separate, unproxied connection). install.sh's "behind" mode (sysop already runs their own nginx/apache; ANetBBS never touches its config) only ever printed one-time setup instructions for the required /mrcws proxy — easy to miss, and with zero ongoing verification that it actually got done correctly, on that install or any later one. update.sh already had auto-patch logic for nginx configs ANetBBS itself manages, but that logic is invisible to a "behind" mode sysop's own separately-located config. Added a real connectivity check — run on every install.sh and every update.sh — that confirms the MRC bridge is answering locally and (when nginx is ANetBBS-managed) that its /mrcws block points at the right port, with a clear, specific warning when it can't verify further (externally-managed nginx). Also clarified deploy/anetbbs-nginx.conf.template's port comments, which read like universal defaults but are actually just "production" mode's example numbers — "behind" mode's real defaults (8080/8081) are different, and copy-pasting the example numbers unmodified was a plausible contributor to the very reports this closes the loop on.

Also fixed along the way: .gitignore's MRC bridge config-secret pattern only covered the bare config.json, not the per-region variants (config.uk.json, config.ca.json, config.bottomless.json) — none of those exist in this checkout so no past release tarball actually leaked one, but the gap meant a future local copy would have shipped in the next tarball built by build-release.sh (which trusts .gitignore as its sole safety net by design, no hardcoded exclusion list). Broadened to config/config*.json with a template-file carve-out.

v1.0b2.231 — Real-name posting policy for echomail/netmail; zip-archive gallery caching fix (July 2026)

Real-name posting policy. Some FTN networks/areas have a real-world policy requiring the poster's actual name rather than a handle/alias — never previously implemented. New per-user Profile preference (terminal + web) for whether to post echomail/netmail as your handle or your real name by default, plus a new per-area (EchoArea.require_real_name) and per-network (EchomailNetwork.require_real_name_netmail — netmail has no area concept of its own) admin toggle. When an area/network requires it, a user with no real name set is hard-blocked with a message pointing them to Profile, never silently allowed to post under a handle anyway. Enforced identically across all six local compose surfaces: web echomail compose + QWK-style netmail compose, web netmail.py's true FTN netmail compose, web telegram.py, terminal bbs_ui.py's _compose_echomail and its second reply-from-read compose path, and PETSCII's _echo_compose — this project's echomail composers have repeatedly needed a fix mirrored across every sibling path, so all six got the identical resolve_post_name() check from a single shared helper rather than one-off inline logic.

Caught by the new tests before shipping, not live: UserManager._user_to_dict() — the dict that becomes session.user for every terminal/PETSCII login — never carried real_name/echomail_name_pref at all, so a terminal or PETSCII user could never pass a real-name-required gate even immediately after setting one in Profile. Fixed alongside the feature itself. 22 new tests, including direct unit coverage of resolve_post_name()'s four resolution branches.

Zip-archive gallery images were re-downloaded on every view. Reported live: zip-sourced gallery images (v1.0b2.223) were "VERY slow." Root cause wasn't the zip extraction itself — a single-member read is cheap — it's that the response carried no caching headers at all, unlike regular image files (served via send_from_directory, which gives browsers ETag/Last-Modified/conditional-GET support for free). Every single page view or pagination click re-extracted and re-transferred every image from scratch, with the browser never able to cache anything.

Fixed: the zip's own stat() (mtime + size) now drives an ETag and Last-Modified header, checked via is_resource_modified() — the same primitive Flask's own send_file uses internally — before the archive is even opened. A repeat request for an unchanged image short-circuits straight to a 304 with no body, without ever touching the zip. Cache-Control: private, max-age=86400 lets the browser skip the round-trip entirely on top of that. Also fixed a smaller inefficiency found along the way: the zip was being opened twice per request (once to find the image inside it, once to read it) — combined into one open. 7 new/updated tests, including one that mocks the read function to confirm a cache-hit genuinely never opens the archive.

v1.0b2.230 — Terminal cursor style: steady/spinning options for accessibility (July 2026)

Feature request from Winzlo: a blinking cursor makes iOS/macOS zoom's "follow keyboard focus" repeatedly recenter the screen on it, fighting anyone trying to look elsewhere (e.g. a menu) while connected — confirmed reproducible across four separate SSH clients (Terminator, PuTTY, WebSSH, ShellFish). ANetBBS never sent any cursor-blink control codes itself; the blinking is entirely client-rendered, so the fix is a new opt-in per-user preference (Profile → Cursor style, editable in both the terminal profile editor and the web Profile page):

  • Default — unchanged behavior.
  • Steady — sends a one-time DECSCUSR (ESC[4 q) at login asking for a non-blinking underline cursor. No ongoing work needed once sent; a client that doesn't understand DECSCUSR harmlessly ignores it.
  • Spinning — a Synchronet-style rotating |/-\ glyph shown while genuinely idle waiting for a keystroke, matching classic BBS behavior. Confirmed via Synchronet's own BAJA scripting docs that their K_SPIN feature is a mode flag on the blocking input-read call itself, not a separate background task — built the same way here (_read_byte_maybe_spinning() in anetbbs/core/session.py), so every input primitive that routes through it (read_key, read_key_arrow, read_line) gets spinning "for free" with no extra state to track across screens, and it can never keep spinning after a real keystroke arrives.

read_raw()'s existing idle-timeout disconnect (unrelated feature, pre-existing) still works correctly with spinning enabled — short spin ticks accrue toward the same overall deadline rather than resetting it each tick.

15 new tests. Also documents the new ANetBBS IRC server (irc.a-net.online, ports 6667/6697, #ANetBBS) in README and the development docs.

v1.0b2.229 — Two more instances of the same TIC extension-collision bug, caught proactively (July 2026)

After three live-caught incidents in two days (.tic, then .mod), did a targeted sweep for other instances of the same mistake in _PKT_EXT_RE before they could bite live too — found two, both confirmed with real regex execution, not eyeballing:

  • .tif/.tie/.tid (TIFF images among them — a very common format). The t[cdih][0-9a-f] branch's own comment already claimed the point character was digit-only, but the regex itself still used the hex range [0-9a-f] — so t + a [cdih] letter + any hex digit (a-f, not just 0-9) still matched. Narrowed to plain digits, matching what the comment already said it should be.
  • .crt (TLS certificates). Collides via the [cdih]rt branch — a fixed 4-letter flavor-code set rather than an overly broad character class, so this one's excluded by extension name instead (same mechanism as .tic), keeping .cut (a genuine, already-tested intentional match on the same branch) working.

Neither had actually bitten yet, unlike the first two — found by deliberately testing every branch of the regex against plausible real-world extensions rather than waiting for the next one to show up in a live feed. 2 new tests.

v1.0b2.228 — Admin cleanup for inbound/processed/, Poll Log fix completed (July 2026)

Requested after confirming inbound/processed/ (where process_tic() moves successfully-filed TIC originals, never deleting them) has no cleanup anywhere — it just grows forever, already sitting at 41MB+ on a single active TIC feed. New "Clear Processed Files" action on Admin → TIC In Log: pick an age threshold in days, confirm, delete anything older. Sysop-triggered only, with a confirmation prompt — never runs automatically, matching this project's usual caution around deleting sysop data.

  • FIX: v1.0b2.227's Poll Log fix was incomplete. The new files_received counter was added and correctly incremented, but never actually folded into the value written to EchomailPollLog.messages_received — the log kept showing "Received: 0" for TIC-only sessions exactly as before, live-caught immediately by testing the v1.0b2.227 fix against a real inbound session. Now actually used.

4 new tests.

v1.0b2.227 — Third TIC extension collision (.mod), inbound Poll Log undercounting, TRUST_PROXY_HEADERS upgrade gap (July 2026)

Live-caught, same day as v1.0b2.226, while watching a real inbound poll against the just-deployed fix:

  • FIX (critical, live-caught): .mod tracker music files inside a TIC-distributed zip caused the whole zip to silently vanish. Third instance of the extension-collision bug class first found in v1.0b2.225: the Mystic day-of-week point-bundle branch ((?:mo|tu|we|th|fr|sa|su)[0-9a-z]) accepted any alphanumeric point character, not just a digit — so .mod (mo + d) collided with "Monday point-bundle". Confirmed live: a 46MB TIC zip (mist0226.zip, ANN.FILES.ANSIART) contained a MELODIA-*.MOD module among its art assets; the one matching member got misrouted into the mail importer ("Imported 0 messages"), and because any match makes the whole extraction non-empty, the entire zip was never written to inbound_dir at all — genuinely missing from disk despite a clean, fully-acknowledged BinkP transfer on both ends. Real Mystic point numbers are a small decimal count, never a letter, so the point character is now restricted to a plain digit. 4 new tests.
  • FIX (live-caught): the inbound Poll Log showed "Received: 0" for a session that had clearly received real data. messages_received only ever counted imported echomail/netmail messages, never TIC files/binaries written straight to inbound_dir — a session that received nothing but a large TIC file (exactly the case above) looked identical in the log to one that received nothing at all. Now folds files written to inbound_dir into the same total.
  • FIX: update.sh now warns if TRUST_PROXY_HEADERS is missing from an existing install's .env. Live-caught on bbs.a-net.fyi itself: this setting is only ever written by a fresh install.sh run; an existing install upgrading through update.sh never got it backfilled, so every visitor IP silently showed as nginx's own loopback address (127.0.0.1) instead of the real client — useless for IP bans or abuse tracking. Not auto-set, since whether it's safe depends on whether a reverse proxy genuinely fronts a given install (and nginx configs aren't always at the path this script expects) — a wrong auto-set would either open a spoofing hole or leave it silently broken either way. Warns and lets the sysop decide.

v1.0b2.226 — TIC manifests with a DOS 8.3-truncated filename couldn't find their own binary (July 2026)

Right after v1.0b2.225 let .tic manifests reach the scanner for the first time, live testing surfaced a second, different gap: some TIC generators write a classic 8.3-truncated name in the manifest's File: field (first 8 characters of the base name + extension) even though the actual attached binary keeps its real, longer filename on the wire — e.g. the manifest says white_pa.zip but the file that arrived is white_paper_3.0.zip. Confirmed live: 6 files in one batch all matched this pattern exactly, each byte-identical in size to its long-named counterpart already sitting in inbound/. This is a different gap than v1.0b2.224's case-insensitive fallback, which only handles a pure case difference, not a length truncation. process_tic()'s binary lookup now also falls back to comparing each candidate file's own 8.3-truncated form against the manifest name — safe even in the rare case of a coincidental match, since the size/CRC checks immediately after still reject anything that doesn't actually match.

9 new tests. Full TIC/hatch/areafix/filefix regression sweep clean, no regressions.

v1.0b2.225 — TIC manifests never reaching the inbound scanner at all (July 2026)

v1.0b2.224 fixed three real bugs in the TIC processor, but live testing afterward (a fresh diagnostic run against the actual inbound directory) showed zero .tic files present anywhere — only the paired binaries. The processor was never even the bottleneck; the manifests weren't arriving in the first place. Traced with help from a peer's own sender-side BinkP log (a Synchronet system, confirming .tic is sent correctly, immediately after its binary, per FSC-0028 convention) to the real root cause, one directory over:

  • FIX (critical, live-caught): every single inbound .tic manifest received over BinkP was silently discarded before ever reaching the TIC scanner. anetbbs/echomail/binkp_server.py (the inbound BinkP listener, used whenever a peer dials into this BBS — the normal way TIC-distributed files actually arrive) has its own file-classification logic, independently duplicated from the outbound poller's (binkp.py), which has no such check and was never affected. Its extension regex for Mystic's point-targeted mail-bundle convention (.tc1, .td2, .th3, ...) — t[cdih][0-9a-f] — also coincidentally matches the universal .tic extension itself: t + i (a member of [cdih], standing for "immediate" mail) + c (a valid hex digit). Every .tic manifest was therefore misrouted into the mail-packet importer, failed to parse as a real FTS-0001 packet (which plain-text TIC content never is), and was dropped — never imported as mail, never written to inbound/ for TIC processing, never logged as a failure of any kind. Fixed by excluding .tic explicitly from the mail-bundle match, regardless of how it happens to line up against the regex.
  • The file-classification helpers (previously redefined from scratch on every single inbound connection, as closures nested inside the connection handler) were hoisted to module level as part of this fix, making them directly testable in isolation rather than only reachable through a full simulated BinkP session.
  • 6 new regression tests (test_binkp_server_tic_extension_collision.py) cover the collision directly (top-level filename and as a zip member name) plus baseline guards confirming the genuine Mystic bundle extensions and real FTS-0001 packets still match correctly.
  • FEATURE: new "Rescan Inbound Now" button on Admin → TIC In Log. Previously the only way to trigger a TIC scan was an actual BinkP session completing (or restarting the whole service) — a sysop diagnosing a stuck file, or verifying a fix like this one, had no way to force an immediate rescan short of SSH access. Runs scan_inbound() against the real inbound directory on demand and reports how many files it found and processed.

199 tests covering this area verified clean (binkp/binkp_server suite + the new tests), full suite unaffected.

v1.0b2.224 — TIC inbound processing fixes (July 2026)

Reported live: "getting files piled up in inbound that are not being processed correctly" — a newly-subscribed ANotherNetwork ANSI-art TIC feed specifically named as an example, sitting unprocessed in inbound/. Traced to three real gaps in anetbbs/echomail/tic.py's process_tic(), all well-known real-world FTN/TIC interop rough edges:

  • FIX (live-caught): CRC comparison did a raw string-equality check against the manifest's Crc: field with no zero-padding. _crc32_file() always formats its own computed value as exactly 8 hex digits, but plenty of real-world TIC generators (older DOS-era tools especially) write the value without leading zeros (e.g. Crc a1b2c3 instead of Crc 00a1b2c3). A file whose real CRC genuinely started with a zero nibble then failed the check on every single retry, forever — not a transient failure, a permanent one. Manifest CRC values are now zero-padded to 8 digits before comparing (and a stray 0x prefix some tools emit is stripped too); a genuinely wrong CRC is still correctly rejected.
  • FIX (live-caught): the binary lookup used an exact-case filename match. Linux filesystems are case-sensitive, and it's common for FTN mailers to re-case filenames on the wire (often uppercasing to classic 8.3-style DOS convention) regardless of what case the TIC's own File: field uses — a binary genuinely sitting right there in inbound/ under a different case was never found, reported as binary not found, and retried identically (and identically failed) on every subsequent scan. Now falls back to a case-insensitive match before giving up.
  • FIX (live-caught): successfully-filed TICs were never cleaned up from inbound/. The binary was only ever copied into the file area's storage — the original .tic manifest and binary stayed in inbound/ forever, on every TIC ever received, success or failure alike, making it impossible to tell from a directory listing which files were genuinely stuck vs. already filed fine and just never swept. Both now move to inbound/processed/ on success (never deleted outright, matching this project's usual keep-it-recoverable posture); a TIC that errors still leaves its files in place in inbound/ so the existing retry-on-next-scan behavior keeps working exactly as before.

10 new tests (test_tic_inbound_processing_fixes.py). Full suite verified clean: 1764 passed, 2 skipped.