SAVE, ECONOMY & ANTI-CHEAT

Other Systems › Save, Economy & Anti-Cheat

One doctrine underneath everything: the client sends events and requests, never state. The local save is a disposable prediction cache, overwritten on every server reconciliation — editing it is inert, not forbidden. This page is the distilled, validated summary; the full inventory of every save field and its authority is docs/state-ownership.md in the client repo, and the 2026-10-22 server-authority run (docs/server-authority-run-2026-10-22.md) is where every door became server-owned.

Who owns what

DataAuthorityStore
Currencies (Scrap, Ichor, Food, Dust, Essence, Ink)ServerThe account’s own server object: an append-only wallet ledger (every row carries its provenance) and per-currency rate ledgers. The wallet in the save is overwritten from them on every save; Wraithgold is never anything but the ledger’s sum.
Creatures, character/class levels, inventory, soulsServerThe account object’s save plus the items and creature ledgers: every creature and item uid is minted by the server, a bag row the ledger never issued is stripped, grades and XP are ceilinged by what the server credited.
Ladder ratings, leaderboardsServerServer-derived (2026-07-30): the client cannot submit a board score at all — see the Leaderboards section below.
Tradeable itemsServerThe same items ledger; nothing is minted by the client. There is no Steam Inventory Service: trading between accounts is the server-owned marketplace (0.93), described in its own section on this page.
Settings, keybinds, autoplay config, local logsPlayerThe settings block of the save: the client writes it, the server stores it as given (last writer wins). The only tier the player is meant to own; nothing competitive or economic lives here. Local logs left with the adventure log (2026-10-22).

The transaction contract

Every economic action is: check server balance → atomic debit + grant → or reject whole. Nothing partial, nothing client-computed. Two mechanisms make "atomic" real rather than aspirational:

MechanismWhat it stops
Idempotency keysEvery request carries a unique key; replays of the same request are no-ops. Retried packets and deliberate double-sends cannot double-grant.
Version tokens (write locks)Cloud Save writes are compare-and-swap on a version token. Two simultaneous spends that both read balance 100 cannot both commit — the second fails and re-reads. This is the actual historical dupe vector: Diablo 3's trillions-of-gold incident was a transaction-layer race in its own auction house, not a hacked client.

Reward path & the accrual cap

The client reports what happened (battle event logs); the server derives reward values from its own tables — a client cannot name a number. On top of that sits the single cheapest, strongest control an autoplay game can have:

Wall-clock accrual cap. Rewards credited per flush are capped by elapsed real time (autoplay runs ~109 battles/h, the economy simulator’s figure, dfmc-client tools/sim_econ.mjs BATTLES_PER_HOUR; the cap leaves headroom above it). A client claiming 4,000 battles in ten minutes is not investigated — it is arithmetically impossible and clipped at the ledger. Farming faster than time permits cannot be expressed in the protocol.

Verification — trust, but replay

EventVerification
Boss kills, floor advancesBounded and tied to a server-known fight, not replayed. The fight is priced against the floor the server clamped it to (below), a claim whose team power is under the floor’s measured band is refused (battle_pw_bad), and a floor-advance or tier-1 claim the server cannot tie to a fight it saw is flagged and never moves the ledger (advance_claim_bad, tier1_claim_bad). There is no move-log replay of PvE; D’s T44 ruling: a legal but unproven fight is credited and flagged (battle_noenter).
Ordinary grind battlesEvery save is checked, none sampled: the wall-clock accrual cap, the per-currency ledger wall and the Ichor ceiling, grow-only merges on progress, and the flag set in the last section of this page (refuse the impossible, correct the rest).
Rated PvP matchesAuto PVP is resolved by the server’s own replay of both teams with their pinned settings — the client never reports a result. A live Competitive match is relayed between the two clients and settled by /api/pvp/finish; the round itself is not yet simulated server-side (see PvP).

The residual threat: farm accounts

Once client cheating is inert, the economy's remaining attacker plays legitimately — fleets of accounts running real autoplay to print tradeable items. The three gates below are design intent — none is built as of 0.93 (there is no Steam market in the game); what the server enforces today is in the sections that follow.

GateEffect
Steam's $5 limited-account gateMarket and trading are locked behind Steam's own spend threshold — each farm account has a real cost floor.
Progression-gated market unlockTrading unlocks only past a progression milestone; a farm account must invest real climb-time before it can sell anything.
Listing velocity monitoringAccounts whose listing rate is inconsistent with their play pattern get flagged for the audit queue — the CS:GO key-fraud lesson: watch the exit, not just the faucet.

The Steam market mirror (S1, 2026-11-03)

Steam’s Community Market trades Steam-owned inventory items; our economy is server-owned. The two meet in a mirror: a creature or a Weapon / Armour / Accessory piece leaves the save when it is exported (the worker asks the Inventory Service to mint a Steam item from the row’s full props) and re-enters when imported (the Steam item is consumed, the exact row comes back under its own uid). Eggs, consumables, souls and Wraithgold are never Steam items (D5). Every step is two-phase and the player’s object owns the atomic half: reserve (ownership, not in a party slot / pen / worn / locked, the account older than MARKET_MIN_SESSION_DAYS, under MARKET_EXPORTS_PER_DAY; the row is taken out of the save and held in market_ledger) → Steam AddItem with our ledger id as requestid (a retry cannot double-mint) → commit; a Steam failure releases the row back. Import: GetInventory proves the account holds the item → import_reserve (bag room) → Consume → import_commit. Refusals cost nothing and are specific: equipped, in_pen, locked, not_exportable, not_owned, then session_young and export_cap. Off everywhere: MARKET_ENABLED unset makes every /api/market door a 404. Runbook: dfmc-client steam/MARKET.md; the farm view is on admin-backend.

Leaderboards are server-derived (ANTICHEAT 2, 2026-07-30)

The studied model is Steam's Trusted writes: leaderboard scores cannot be set by clients — only the publisher's own server may write them. Client-writable boards die predictably (every Papers, Please endless board tops out at a forged 2,147,483,647). Herumon Tower follows the same rule: /api/lb/submit is retired (HTTP 410) and no client-callable board write exists. Every board value is derived from truth the server already owns:

BoardSource of truthWrite policy
ladderThe server-owned Elo. Folded from the ratings table once per snapshot day, at the board's own recut — finish-time board writes were removed the same day (D ruling): the board read is daily anyway, so per-match freshness was invisible and cost a write per match. Ratings live in the world object, so the fold is a bounded in-object pass over pvp-active players; unchanged rows bill nothing.set — the ladder shows the current rating; the row's first-arrival timestamp is kept.
climbThe maxFloor ratchet (progress), advanced only by credited battles. When a battle ratchets, the player object annotates its response and the Worker relays the new value to the world object under waitUntil — objects never call objects, and the player's response is never delayed.max — keep-best; monotone by construction. ~1 write per floor per player lifetime.
powerThe one value with no server recompute yet (that is ANTICHEAT 5). It rides the save as pw, is stripped from the stored record, and is validated in the player object against the ceiling for the floor past the ratchet: the Max_Power column of codex/power_band.csv (since 2026-10-23; each row is the old 2,000 + 1,500×f line at the row’s top floor). A claim over it is flagged lb_power_over; a missing table, row or column is no ceiling and no flag. The battle door has its own power floor, a measured table since 2026-10-23: on a non-boss battle it computes the stored save's power, and if it is above zero but below the floor's row of codex/power_band.csv (Min_Power) the battle is refused battle_impossible and flagged battle_pw_bad. A row of 0, a floor no row covers, or a missing table is no check (a missing table flags battle_pw_noband and credits).max — keep-best, deadbanded (only an improvement over the last published value is relayed, so a 10-second autosave cadence can never become a world-write cadence). The validated value also persists in the database (pwlast, 2026-07-31) so the daily fold can serve it forever after. The climb ratchet also rides the save the same way; the world's keep-best absorbs any redundant publish.

The daily database fold (D ruling 2026-07-31: "the database needs to populate the rankings once a day — logging in should not change anything"). The daily backup sweep — the Worker cron that already walks every registered account to mirror saves — now carries each account's board facts back in the same hop (ratchet, persisted validated power, backend name; zero additional per-account requests) and folds every board through one batched sealed write per page, then drops the day's snapshots. Live event publishes remain as same-day freshness on top; the fold is the completeness floor that makes the boards independent of who happens to log in. Power itself is computed server-side (2026-07-31): src/power.js is a line-faithful port of the client engine's charPower() closure — item implicit grids, sockets, souls, class and creature math — parity-gated to EXACT equality against the live client on real saves before it ever touched a board. The fold derives every account's power from the save it is already carrying (mode set: power is the current build and drops when gear does), so every account with a save is on the board — no client involvement at all. The client's pw claim is demoted to telemetry: it persists as pwlast and the fold cross-checks claim vs computed, surfacing any mismatch (a port bug or a forged claim — both must be seen).

Failure & cost doctrine

Precedent says the model holds: the mobile-strategy pattern this follows (client as pure view, all authority server-side) is why modded clients achieve nothing against such games. Its known failure modes are the transaction layer (handled above) and cost blowout — the naive build of this same architecture (UGS Economy ledgers, per-round PvP server resolution) priced at >$450k/month at 500k CCU; the committed build lands at ~$0.02–0.03 per concurrent user per month (~$12–15k/month at 500k CCU), dominated by batched Cloud Save writes. Flush tiers: immediate for stakes (deaths, trades, ladder), batched for grind accrual.

Open item: SAVE_ARCHITECTURE.md v2 (with the five review corrections folded in) is written but not yet committed to the repo. It belongs next to PIPELINE.md.

A throw in renderVals is a blank screen, not a broken row

renderVals() builds the entire flat object the template renders against. It is all-or-nothing: one exception anywhere inside it and every {{ binding }} on the page fails to resolve at once. The player does not see a missing name — they see the whole screen vanish, and then reappear seconds later when the next good state renders.

Report #20 is the worked example. A wild spawn whose row could not be resolved (Spawn '?' has no Line_ID in creatures.csv) becomes a unit with name === undefined. confirmRows did tn = u[q.target].name and then sized the label with targetFs:(tn.length<=11 ? 18 : ...). The read threw, renderVals died, and the page went blank mid-battle. The player's log tape shows it exactly: an asset failure on codex/images/med/undefined.webp, then TypeError: Cannot read properties of undefined (reading 'length'), then eleven consecutive never resolved — rendered as empty warnings for round, streakX and every unit portrait in one burst. That burst is the signature of this failure class.

An empty string is not the same as undefined, and this is the trap. ''.length is 0 and renders perfectly; only undefined throws. A reproduction built with name: '' therefore looks completely healthy and proves nothing — which is exactly what happened on the first attempt to reproduce this. tools/probe_r20.py keeps both as controls for that reason.

The doctrine. Anything renderVals reads that ultimately came from a CSV row, a spawn descriptor or a save is data, and data can be absent. Never read a property off it unguarded, and never read .length off it at all without a fallback. The cost of being wrong is not a cosmetic defect — it is the whole client, mid-battle.

Client: confirmRows in renderVals(), play/app.js. Probe: tools/probe_r20.py (4/4).

A battle is priced against the floor it was clamped to, not the floor it claimed

Most of this was already closed and the surviving notes about it were stale. Verified against a real worker, not read: ANTICHEAT 2 clamps the event's floor to ratchet + 1 before it prices anything; STAGE 4b derives the primary kill from the server's own seeded slot so the client's claimed tier and level for it are ignored outright; extras clamp to the primary's tier and floor ±3; with no slot every claimed kill clamps to the floor's own weight table; and _table is injected by the Worker, so a client-supplied one is overwritten.

What was still open was a sequencing mismatch between two individually-correct steps. The Worker fetches the spawn table for the claimed floor, because it must — the object may never be handed the spawn data. Only afterwards does the object clamp the floor. So on a forged claim the table described floor 99 while the pricing described floor 2, and the tier check asked "can floor 99 spawn an SSR?" Rarity is a 7× multiplier on both XP and Ichor.

Measured before the fix: a forged floor-99 SSR claim paid 18× the XP and 9× the Ichor of an honest floor-1 kill, on an account whose ratchet was 1. Bounded by the rate cap and the spawn budget, so not unlimited — but this is the value per battle gap, which those caps do not address at all.

Fix: the Worker stamps _tableF with the floor the table describes. The object compares it to the clamped floor and, on a mismatch, refuses to price against it: the slot path will not derive an identity from it, the tier check falls back to Common, and it flags battle_table_mismatch. The battle is still credited — a confused client should not lose a fight — it simply cannot be paid for a floor the account cannot reach. An honest client never claims above ratchet + 1, so the clamp never fires for it and the stamp always matches.

Measured after: 1.0×, like-for-like against an honest Common at the same clamped floor and the same legitimate +3 spawn level.

Three false readings on the way, all in the test rather than the code, and worth recording because each one first looked like a finding: a missing x-ht-epoch header made every forgery "refused" for the wrong reason; a floor-1 baseline against a claim that clamps to floor 2 read as 2.5× residual; and a level-2 baseline against a legitimate floor+3 spawn read as the same. Only a comparison whose own controls pass means anything.

Worker: _tableF stamp on the /api/battle pre-pass. Object: tblOk in the battle handler. Probe: tools/probe_battleclaim.py (3/3).

Every flag refuses, corrects, or is gone (server-authority run, 2026-10-22)

The rule (research R5): refuse the impossible, correct the rest, flag always. No flag still watches while it pays. "Post-wipe" means the account's wipe epoch is 2 or higher. Before that, ruling #3 (no balance refusal during BOOK) still holds, so an honest pre-wipe save is never refused.

Changelog

2026-11-03 — the Steam market mirror is BUILT, off (board S1 “marketplace for mons and items”, server half; D 2026-11-03 “I don’t see anything about steam marketplace for mons … Build those too”; dfmc-client lane marketsrv). On dev. New section “The Steam market mirror”. Doors POST /api/market/export {kind, uid}, POST /api/market/import {steam_item_id}, GET /api/market/exports, ops GET /api/ops/market/exports?steamid=; the object’s market_ledger (reserve → Steam → commit / release; import_reserve → Consume → import_commit), the World’s market_world mirror for the farm view. Itemdefs generated from the codex at DATA_PIN (steam/itemdefs.json: 1000 + creature-line ordinal, 5000 + Item_ID; 104 + 180). Exportable by manager default until D corrects it on the form: creatures of any stage / grade, mutants included, and Weapon / Armour / Accessory; never eggs, consumables, souls, Wraithgold; fee 10 %. Flags MARKET_ENABLED (off), MARKET_MIN_SESSION_DAYS 7, MARKET_EXPORTS_PER_DAY 5. Gate tools/probe_market.py against a mocked Inventory Service, 5/34 → 34/34; gen_itemdefs.py --check in gates.sh. The in-game UI is lane marketui.

2026-11-03 — codex review (dfmc-client lane codexreview-a; D 2026-11-03 “review the codex, clean it up, update whatever needs updating”). WIKI ONLY. The “Who owns what” and “Verification” tables still described the Unity-era design (Cloud Save keys, Cloud Code replays, Steam Inventory Service, Steam Cloud settings); they now describe the server as built — the account object’s ledgers, server-minted uids, power-band pricing and claim tying instead of PvE replay, the Auto PVP resolver and the relayed Competitive match. The farm-account gates are marked as unbuilt design intent, and the lead points at docs/state-ownership.md instead of a spec file that does not exist. Structure unchanged.

2026-10-23 — the accrual-cap pace is the simulator’s (dfmc-client lane a8-s5-c17-tail, C17 part 4). WIKI ONLY. “~30 battles/hour” → ~109 battles/h, generated (block bph-integrity) from dfmc-client tools/sim_econ.mjs BATTLES_PER_HOUR.

2026-10-23 — the leaderboard power ceiling is in the codex too (dfmc-client lane battle-tidy; CSV truth). The save-time ceiling on the claimed power (lb_power_over) was a formula on two wrangler vars, LB_POW_BASE 2,000 + LB_POW_PER_FLOOR 1,500 × floor. It is now the Max_Power column of codex/power_band.csv, the same table as the battle power floor, read for the floor past the ratchet. Each row holds the old formula at the row’s top floor (17,000 for floors 1–10 up to 182,000 for 111–120), so no claim the vars passed is flagged now. A missing table, row or column means no ceiling and no flag. The two vars are gone from wrangler.jsonc. SHIPPED IN 0.90; release when the ritual advances the data pin.

2026-10-23 — the battle power floor is a measured codex table, not a formula (dfmc-client lane power-band; research R5 "refuse only the impossible", T44). SHIPPED IN 0.90; release when the ritual advances DATA_PIN and bakes the csv. The battle door refused a non-boss fight when the stored save's power sat under (LB_POW_BASE 2,000 + LB_POW_PER_FLOOR 1,500 × floor) / 9. That line was written against older numbers, and after this run's balance changes (team souls, stage grades) it sat above honest play: D's own save, power 8,286 at floor 52 and winning its fights, was refused at 8,888. The floor now comes from the new codex/power_band.csv (Floor_Lo, Floor_Hi, Min_Power, plus the two measured columns behind it). tools/power_band.py derives it in the client repo. The fight sim runs every saved account on every wild floor. For each zone, Min_Power is the lowest power that wins any fight there divided by 3, and never more than the strongest power that loses every fight there. A zone where no account loses everything has Min_Power 0, which means no check (floors 1–20). Old floor → new floor: f1–10 388–1,722 → 0; f11–20 2,055–3,388 → 0; f21–30 → 600; f31–40 → 750; f41–50 → 1,150; f51–80 (old 8,722–13,388) → 2,750; f81–90 → 4,250; f91–110 → 6,400; f111–120 (old 18,722–20,055) → 11,300. If the table is missing, no check runs: the battle is credited and flagged battle_pw_noband, never refused. The save-time leaderboard ceiling (LB_POW_*) is unchanged.

2026-10-22 — the wipe of 2026-10 is DONE on production (release 0.89; Tower rulings form card T37). WIPE_EPOCH 1 -> 2 shipped with the 0.89 release push (main 30bc1ff3, Workers Builds check runs 119998338066 dfmc-client and 119992010414 dfmc-client-dev, both success); the follow-up hotfix dd318b0e (the World object's first request after a wake 500'd with SQLITE_LOCKED; check runs 120005860063 / 120005850305, success) is live too. Before the push every account was exported from production (23 accounts: saves, facts and the four boards, kept off the repo in the operator's local backups, folder 20261022T1754Z). The deploy window answered 503 for under 40 seconds. Verified after: 22 of the 23 accounts carry the global_wipe flag and hold no live save; the 23rd never saved and is skipped by design (the reset runs on a session touch or the daily sweep, and an account with no save, name, boon or wallet row has nothing to erase). D’s own account came back at ratchet 1. The roster survives the wipe as it should: /api/backup/accounts still lists 23 ids, and a forced backup sweep walks all 23 and mirrors 0 saves. Still to check: the paid-currency pre-check (runbook § 4) and D’s Wraithgold read need a GM session; D’s in-person checks (runbook § 6) and the Auto PvP queue are his.

2026-10-22 — flags that refuse (server-authority run § 3 item 11). LIVE on release since 2026-10-23 (0.89); the post-wipe parts start when the account reaches wipe epoch 2. Every watch-only flag is now a refusal, a correction or deleted (section above). The save door takes out unissued, spent and unminted bag rows before the write. A mismatched socket is moved in the items ledger, and a doubled socket is refused. The roll door refuses a soul uid the ledger never issued. The wallet_rate soft line is gone post-wipe. An empty party slot ({name:''}, what a class switch writes) is no longer flagged party_unminted. crxp_over_ledger now answers the clamped map in srv.crXp, not the word 'clamped'. Gate tools/probe_sa_flags.py 6/13 → 14/14. Table: dfmc-client docs/server-authority-findings-2026-10.md § flags.

2026-10-22 — the wipe of 2026-10 is a FULL wipe (Tower rulings form cards T37: "This is a FULL WIPE of ALL PLAYER SAVES. no History, no Names, nothin stays. Entitlements do not stay either"; T47-1: "Wipe it, we dont owe anyone anything"; T33: seasons are removed; T39: account history stays on the server forever). DONE on production 2026-10-22 (entry above); the dev world (playdfmc.com/dev) was wiped the same day as the rehearsal: WIPE_EPOCH 1 -> 2 on the dev branch, the one dev account reset on the sweep (global_wipe flag, no live save, ratchet 1, ledger zero), health 200, no worker errors in the tail. The full reset now erases what the 10-01 and T3 entries kept: every Wraithgold row including one with paid provenance (nothing is paid today and D ordered nothing kept), every entitlement (shop boons and Auto PvP season rewards), the account's claimed name (the world's claim row goes with it, so every name is free again and is claimed first-come at creation, the old holder included), the weekly trade rows, and the ops Ichor credit of the …5556 case with the ledger it sat in. The world object also clears the Auto PvP season history and its pending rewards, the won-with lineups and the pinned pre-wipe match snapshots. An account that never saved but holds a name, a boon or a wallet row is reset too. Kept, and only this: the account itself, its generation counter (bumped), the audit tables (anomalies, flags, the account log) and the idempotency tables. Runbook: docs/wipe-runbook-2026-10.md in the client repo, § 0 tables and § 8 notice re-cut. Probe: tools/probe_wipe.py (38/49 on the T3 tree, 49/49 after).

2026-10-01 — the wipe of 2026-10, PLANNED (Rulings board cards wipe / grades: "Do the wipe"; refund064: no refunds). LIVE on release since 2026-10-23 (0.89); the wipe ran on production 2026-10-22. The wipe is WIPE_EPOCH 1 → 2 in the worker config, after a release ships the client. Each account's object resets itself on its first session touch or on the daily sweep, whichever comes first. The reset deletes the save, its history and parts, the climb ratchet, the battle chains and XP/food ledger, the Abyss run, the account's PvP rating, and five things a dry run found it used to leave standing: the item registry and item grants (a pre-wipe item re-presented by an old copy of the save would have read as server-known), the breeding pen anchors (pen 0 is always owned, so a pre-wipe pair could have been claimed as an egg), the last published power (it would have re-published the old power), and the wallet ledger's earned rows for Ichor, Scrap, Dust, Essence and Ink. That last one is D4's seed-delete; the save it balanced is gone, so the opening balance is zero. Wraithgold is kept, every row. It is the only currency with paid provenance, and a wipe is neither a refund nor a confiscation. Entitlements, names, bought slot caps and bought pen unlocks are kept too. The world object clears the boards, the PvP ratings and the Auto PvP ladder and defence snapshots once, on its first request at the new epoch. Before this fix, a wiped account still ranked at its pre-wipe floor because the climb board keeps each player's best. Auto PvP season history is kept. Taking effect with the same bump: the grade freeze (entry below). Still a separate decision after the wipe: every per-door enforce flip (D4 step 4). Runbook: docs/wipe-runbook-2026-10.md in the client repo. Probe: tools/probe_wipe.py (27/33 before, 33/33 after).

2026-10-01 — an item's grade is the server's once the account is past the wipe (Rulings board card grades; review F174/F234, non-egg part). LIVE on release since 2026-10-23 (0.89), and it takes effect when the wipe bumps WIPE_EPOCH to 2. One flag decides it: GRADE_FREEZE_EPOCH = 2 in the worker, compared with the wipe epoch the account's object has claimed (kv __wipe_epoch). The wipe catch-up writes that epoch before anything else, and a brand-new account claims it too. On a post-wipe account, only the server's doors set an item's grade: the drop, hatch and breed grants, the roll door's win and the devour door. The save door compares every uid-carrying instance whose grade moved against the stored save with the ledger. Where the ledger names a grade, the save is put back to it before it is written. The answer carries srv.grades, and the save door flags grade_forged but does not refuse the save. The ledger row's grade, category and tier no longer follow the save for any category (eggs already did not). The save may still fill in a blank field, such as a drop row born with an empty category. Saves from before the wipe behave exactly as before. Per D's 2026-08-18 ruling there is no backfill of grades the client named, and the wipe is the line. Items with no uid (most socketed gems and runes) have no ledger row, so this cannot hold them. Probe: tools/probe_grades.mjs 7/21 → 21/21.

2026-09-30 — the save door's kept bands stop compounding (review P6 F6-001, F6-002, F6-009; S1/S1/S2). ON DEV. The XP, class XP and Ichor ceilings kept an in-band claim and then measured the next save from it, and the wallet rate guard did the same with its hard burst: a forger repeating an in-band save minted a band per save. The base is now the server-credited part of the stored value (the uncredited excess is stamped server-side, _ovX/_ovC/_ovF/_ovW), so each band is one band in total and the wallet burst is a bucket that refills at the authored rate. Nothing new refuses: Scrap/Dust/Essence/Ink still have no balance ceiling (ruling #3, 2026-08-13), and their ledger comparison is the watch-only flag wallet_over_ledger. The save's ts is bounded to server time + 2 minutes. Full entry on system-networking.

2026-09-30 — review drift fixes: F194 (project review, codex fix lane). Text only; no code changed. The power row now names the battle door's watch-only floor-plausibility flag battle_pw_bad (BATTLE_PW_MARGIN, default 9). The save-time ceiling POW_MAX with (ratchet+1) was already right; the two are different checks, not a formula conflict.

2026-09-30 — the items ledger holds egg identity and spent rows against the save (project review F233, S1; F234 egg part, S1; F235, S2). ON DEV, not yet released. The save door keeps the items ledger in step with the bag, and three of its paths trusted the save more than the hatch and spend doors assume. Now: (1) an egg hatches only off a ledger row that came from a grant or the one-time seed — a row the save door adopted as unsourced is refused (hatch_unledgered), the same rule the boss-key door already applied; (2) a save can no longer change the grade, name or category of an Egg row, nor turn another row into an Egg — only the server writes egg grades (the breeding repair); (3) a row a server door spent (dismantle, hatch, boss key, breeding repair) stays spent when a later save still carries its uid, and raises the new inv_spent_back flag; only a row that merely left the save re-opens. Unchanged: grades of non-egg items still follow the save (open question for D: roll catch-up and devour rely on it), seeded and granted eggs hatch as before. Gate tools/probe_f233.mjs 5/14 → 14/14.

2026-09-29 — the save door no longer accepts server stamps from the client (project review F044, S1; F045). ON DEV, not yet released. The Worker prices each dismantle and derives the other save-door stamps the player object trusts, but a crafted save could carry its own copies of those stamps and, on some paths, they reached the object unchanged; a save the Worker could not parse was forwarded as sent. Now: every server-internal field is dropped from the incoming save before the Worker computes its own, and a save that cannot be parsed is refused. Only the Worker’s own pricing reaches the ledger. Also: when a forged-scale player XP claim is clamped, the stored value is now reported back in srv like every other clamp, so the client adopts it instead of re-claiming it every save. Unchanged: honest saves, the dismantle door, and the outage rule (an unpriced claim stays pending). Gate tools/probe_saveforge.mjs 1/6 → 6/6.

2026-09-28 — the first-win bonus is granted by the server (D, 0.86 board 2026-09-25: “Legitimate drops/grants are server authoritative drops/grants, NEVER client side granted”). SHIPPED IN 0.87. The last client mint the Q6 audit found is closed. Wild wins 1–10 used to roll first_win_drops.csv in the browser with Math.random(), off the browser’s own win count, and seat the Worn T1 items and the egg with uids the browser made up. Now: /api/battle counts the win itself — a credited, non-boss victory with kills, the same rule as before — and rolls the same table on keyed draws. The items get server uids and are minted into the grant lane with that battle’s other drops, and the victory screen shows them as First-win bonus. The browser rolls nothing. Unchanged: the odds and the item table: Worn T1, the same families and the same fixed pieces, win 4’s accessory the other family from win 1’s. Existing accounts: the server count starts once from the save’s win count, so an account already past win 10 gets nothing again and one part-way through carries on. A battle sent twice grants once, because it is deduplicated by its event id. Gate tools/probe_firstwin_srv.py 4/13 → 13/13 (client plus a real wrangler dev worker).

2026-09-28 — every client path that mints or rewrites an item uid, audited (Q6, D: “drops and grants are server-authoritative, never client-granted”). SHIPPED IN 0.87. Server-minted and only carried: battle drops (once-only since #960), boss loot, keys, eggs sent with a uid, the forge, and admin grants. Named locally, identity only (no item is created): the starter creatures, the ensureUids stamp on legacy records, and the fallback when an older worker answers a hatch, a breed or an egg drop without a uid. Fixed today: equip and socket seated items they had not consumed; one piece taken off a stack carried the stack’s uid; and optimiser, unequip and refund paths dropped uids that the next boot then re-stamped as new “legacy” items. Still client-granted, handed to D: the first-win bonus (wild wins 1–10) rolls its Worn T1 items and egg in the browser with Math.random(). It needs a server lane.

2026-08-29 — queuing an ultimate no longer blanks the battle screen (#642). On release. Choosing a move, going back from the confirmation window to change the hero's, and then picking the ultimate emptied the screen. An ultimate is queued differently from an ordinary move — it is not one of the four in the kit — and three places on that screen look a queued move up. Two of them already coped with not finding one; the highlight ring that follows your pointer did not, and clicking a unit to change its move is precisely what puts the pointer there. One failure inside the drawing pass takes the whole screen with it, which is why it went black rather than simply showing nothing. All three now resolve an ultimate properly, so it reads correctly everywhere instead of merely not crashing in the one place it did.

2026-08-29 — a battle chain no longer diverges silently (#638). LIVE on release since 2026-09-01 (0.65). The chain check reported battles arriving on a head the server never issued. The account it fired on also sent a stale save and a short codex the same day — three detections, one device whose local writes were not landing. The client advances its chain head and stores it; that store sat in a silent catch, so a device with a full browser store moved the head in memory, failed to write it, and on the next reload sent the old one — a break on every battle from then on, looking exactly like a forged claim. The head is now read back to prove it landed, and if it did not the chain is abandoned so a fresh one starts: one flagged break instead of permanent divergence. Two things found and not changed: the check still refuses nothing and overwrites its stored head with whatever arrives, so it repairs itself to the claimant's version; and the answer carries a verdict field the client has never read. Enforcing is a separate decision, because it would refuse credit to a legitimately resumed battle.

2026-08-29 — a mass dismantle stops re-reading the whole ledger for every item (#632). On release. One batch wrote 173 ledger rows against a normal ten an hour, and the question was whether that many rows are needed. They are — each row is keyed to the item that produced it, and that key is the only thing that makes a retry safe; the item's tombstone is explicitly not, because a stone unsocketed and dismantled in one flush has to be re-verified from scratch. Merging them would double-credit a retry that overlapped a previous one. The cost was never the rows. Each one scanned the entire ledger to check its key, and summed the whole balance twice — once to compute and once to check for drift. That is 519 read operations for one dismantle. The batch now takes one scan and carries each currency's balance forward, with the drift check running once per currency at the end: 519 reads become 11, the same guarantee for a forty-seventh of the work. No index was added, because an index entry is itself a written row on every insert.

2026-08-29 — two detectors stop flagging the server's own arithmetic. LIVE on release since 2026-09-01 (0.65). The currency-rate guard was reporting bulk dismantles as suspicious income: the Ichor burst allowance is 1,500 while one batch pays far more, and the same account in the same hour shows 173 dismantle rows in a single request. The server had booked that income itself, so the guard was flagging a payout it had just made; the booked amount now joins the allowance, exactly as the Shadow Trader's credit already did on the line above. The clamping tier is untouched and remains the only one that ever refuses anything. Separately, the egg-claim check flagged pens finishing one second early on forty minutes — three anomalies across three days measured at 0.13%, 0.04% and 1.08%, all of them anchor drift and device clock skew. It now allows the greater of sixty seconds or two per cent of the pen, and anything past that still flags and still reports by how much. A detector that cries wolf gets ignored, which is the whole reason both were worth fixing.

2026-08-28 — the floor cache keeps the last five floors instead of all of them. On release. The client kept the full encounter for every floor it had ever visited — each of a floor's ten slots holding its creature and the two battle opponents the server sends with it, so ~13 KB a floor and ~1.58 MB across a full tower. That was sixty per cent of everything the device is allowed to store, larger than the save and every battle log put together, and nothing had ever removed an entry. Nothing is lost by dropping one: the server derives each slot from a keyed hash of account, realm, floor, version, slot and sequence, so it reproduces the identical encounter on demand — and the game already asks it for the floor's truth every time you arrive. The cache only paints the screen for the moment before that answer lands.

2026-08-28 — a battle can no longer start against a spawn that is already gone. On release. A player on floor 19 saw “Spawn ‘?’ has no Line_ID in creatures.csv”. The data was innocent: that floor's ten creatures resolve on live codex and at the pinned copy release serves, and the server can only pick ids it read from the same table. Autoplay picks a slot, cues a 650 ms pan, and starts the fight on a timeout — and any floor sync inside that window replaces the whole pool. The queued start re-checked autoplay, phase and transit but never that the slot was still there. A slot the server reports empty carries no name and no line, so the fight opened against a creature called undefined with a placeholder move set — the same shape of unresolved unit that once blanked the entire battle screen. The click path could never do this, because a dead slot draws nothing to click; only a stale index reaches it. The target is now checked where it is used, and a moved pool triggers a resync.