BACKEND, SAVE & ANTI-CHEAT

Hub › Dev Tools › Backend, Save & Anti-Cheat

Canonical spec (v2, July 2026) for persistence, the server-authoritative economy, Steam integration, anti-cheat, and live-service cost. Supersedes the v1 draft. Stack per GDD: UGS (Auth, Cloud Save, Cloud Code, Leaderboards, Remote Config) + Steamworks.NET + Steam Inventory Service. One sentence: the client sends events and requests, never state; the server owns state and computes every result; Steam Cloud carries preferences only.

The principle & trust boundary

Local editing is not prevented — it is made inert. "I have 999,999 Scrap" is state and is never an input to any write. "Craft recipe R" / "I completed battle #8837" is a request or event, validated against the server's own copy of the data. The client keeps a disposable prediction cache for responsiveness; every Cloud Code return overwrites it with the authoritative snapshot. There is nothing to clean up after a cheat attempt because the illegal state never existed server-side.

CLIENT — UNTRUSTED Prediction cache optimistic, disposable, overwritten Event / request buffer battle logs · craft requests Settings · keybinds · logs the ONLY client-owned data edits here change a private fiction — never the input to an authoritative write TRUST BOUNDARY UGS CLOUD CODE — THE GATE validates vs server truth computes rewards from tables atomic debit + grant wall-clock rate cap sampled deterministic replay idempotency + writeLock mints Steam items (publisher key never on client) UGS CLOUD SAVE Protected: client read-only Private: client no access STEAM INVENTORY tradeable ownership · server-minted LEADERBOARDS STEAM CLOUD settings only — never authoritative events snapshot file sync — settings only

Fig. 1 — Trust boundary and storage topology. Everything that gates loot, rank, trade, or death lives right of the boundary.

Data classification

Every persisted category maps to one authority, one store, one access class, one flush policy. Rule of thumb: if faking it affects loot, rank, trade, or death → Protected/Private on UGS, server-written. If faking it only affects the player's own convenience → client-local.

DataAuthorityStore · classFlush
Currencies (Scrap, Ichor, Food) + materials (Dust, Essence, Ink)ServerCloud Save · ProtectedBatched; immediate on rare gains
Inventory, eggs, creature instancesServerCloud Save · ProtectedImmediate on craft/grade/hatch/fuse; batched on drops/XP
Character, class levels, playerLevel, CodexServerCloud Save · ProtectedBatched
Floor record / PvP rankServerLeaderboards + Cloud SavePersonal best / post-match only
Tradeable items & creatures (ownership)SteamSteam Inventory ServiceServer-minted via publisher key
Autoplay config, keybinds, graphics, UI, exported logsClientSteam Cloud + localBest-effort

Access classes are the structural wall — a Protected/Private key physically cannot be written by the client SDK. Validation logic is the second layer. State is written as consolidated documents per realm, not per-field keys — every extra key per flush is another billed write.

The economy transaction contract

Every spend — craft, grade-up, soul grade, breed, enhancement — is the same atomic transaction computed against the server balance. Ordering is the entire guarantee: check, then atomically debit + grant, else reject whole. RNG rolls (the grading 100→0.5% ladder) resolve server-side, so a failed roll cannot be re-rolled by save-scumming — the currency is already gone on the server. Costs come from the same authored tables as the GDD (25×tier Scrap crafts, the 1/1/2/3/5/8/12/18/40 Ichor ladder, the five-material soul bundle).

The material wallets (T19a, 2026-10-22). From the wipe, Scrap, Ink, Dust and Essence are exactly SUM(delta) of wallet_ledger for the account: every mint is a door’s earn row (dismantle, the save door’s dismantle verification, the Shadow Trader’s target, an ops credit) and every spend a door’s spend row (roll, class, trade, slot, shop), checked against that sum and refused insufficient when it does not cover the price. The stored save’s wallet is a projection of the ledger that the save door clamps to it (wallet_over_ledger). Ichor is the one currency still judged by the ledger.food_total ceiling below; Wraithgold by the entitlement ledger.

REQUEST: craft T7 (175 Scrap) idempotency key attached client's local claim: 999,999 Scrap never read — not an input READ server truth Scrap = 40 covers cost? no REJECT — whole op no debit, no grant snapshot {Scrap: 40} phantom items evaporate on client yes resolve() — server RNG grade odds roll here ATOMIC: debit + grant all or nothing (fail = currency only) single doc write → snapshot Validation is a gate, not an audit: the illegal craft never executes, so there is nothing to reconcile, no orphaned items, no cleanup.

Fig. 2 — Transaction gate. The "player edits Scrap and crafts ten items" scenario dies at the first read: server sees 40, all ten requests reject, zero items ever exist.

Rewards, batching & flush tiers

Rewards are derived server-side from completed battles; the client cannot assert an amount. Per event, Cloud Code recomputes Food (round(2×(1+lvl/20)×RarityMult)), XP with the level-penalty curve, and rolls the 10%/10% gear/enhancement drops from server tables — then applies one aggregated write. Batching groups events at the Cloud Code boundary: it changes how often we write, never who decides what is written.

battle stream LOCAL EVENT BUFFER {id, floor, seed, moveLog, result, prevHash} IMMEDIATE — own call, flush now SSR/SR drop · boss kill · new floor record hatch · fuse · advance · craft/grade result any trade — never buffered BATCHED — one call per timer fodder XP · Food · common drops · codex-seen flush every T minutes — TIME-based, never count-based Cloud Code invocation ① wall-clock rate cap Σ min-seconds ≤ elapsed (Fig. 4) ② recompute rewards Food / XP / drops from server tables ③ deterministic replay 100% value-gating · ~1% random sample +ΣFood · +ΣXP · drops · record — one aggregated write → snapshot

Fig. 3 — Reward path. Immediate-tier events can never sit in an unflushed buffer; batched-tier loss on crash is bounded to a few minutes of personal grind — an availability tradeoff, never an economy hole. Replay reuses the GDD's determinism primitive (initial state + seed + committed move log).

Rate cap — variable battle speed

Battle duration is not constant: a floor-1 one-shot is far faster than a 5-turn endgame fight (the economy sheet's 280 Food/h at floor 1 already implies ~85+ battles/h there vs ~30/h endgame). So the cap is a curve derived from each battle's committed turn count, not a flat battles-per-hour number. Elapsed time is measured between Cloud Code's own flush timestamps — client clocks are never trusted.

server-observed elapsed since last flush (Cloud Code timestamps) — e.g. 1,200 s over budget 1-turn one-shots ≈ overhead + 1×turn ≈ ~10 s each 5-turn fights ≈ 25–30 s clamped · discarded · flagged credit while Σ ( overhead + turns × perTurnTime ) ≤ elapsed × (1 + tolerance) Floors don't set the cap — the committed turn count does. Floor-1 one-shots ≈ up to ~300–350/h legal; endgame ≈ ~30/h. Constants live in Remote Config, margined ~20–30% under observed legit minimums; derived from the same battles-per-hour-by-floor assumption as the economy sheet, so cap and faucet can never drift apart.

Fig. 4 — Turn-count time floors. Guarantee: no account accrues faucet output faster than the design pacing curve for the content it fights. A headless client can forge 1,000 logs in 10 s of CPU — they credit at design pace, so the farmer gains exactly nothing over legal autoplay. Forged 1-turn logs against enemies the server-known party can't one-shot fail the sampled replay.

Log integrity — hash chain

Each battle event carries the previous event's hash. The server stores only the chain head per session; full logs stay client-side and are requested on audit. Streak and floor-record history becomes tamper-evident at ~zero storage cost.

e₁ · prev: ∅ h₁ = H(e₁) e₂ · prev: h₁ h₂ = H(e₂‖h₁) e₃ · prev: h₂ h₃ = H(e₃‖h₂) server stores chain head hₙ only audit: request full log → verify chain → replay sample

Fig. 5 — Hash-chained event log. Editing, inserting, or deleting any past battle breaks every subsequent hash.

Concurrency — the actual historical dupe vector

Server-authoritative games get robbed through their transaction layer, not the client. The classic dupe: two simultaneous spend requests both read balance 100, both pass validation, both commit. Two mechanisms close it, both mandatory before trading exists: every request carries an idempotency key (retries return the cached original result), and every Cloud Save write uses the writeLock version token so concurrent writes to the same player document conflict and retry serially.

REQ A — spend 100 idem key K₁ REQ B — spend 100 idem key K₂ · simultaneous both read: balance 100 document version v7 A writes if version == v7 → OK v7 → v8 · balance 0 B writes if version == v7 → CONFLICT stale token — write refused B retries: reads v8, balance 0 → REJECT (insufficient) Retry of A (same key K₁, e.g. after network drop) → idempotency cache hit → returns the original result. No double debit, no double grant — ever.

Fig. 6 — Optimistic concurrency + idempotency. 100 gold cannot become 200 gold of spending regardless of request timing.

PvP — commit-reveal, verify once

Rounds run peer-to-peer over Relay; the server is not in the per-round loop. Each side commits hash(move‖nonce), then reveals; the round's RNG seed derives from both nonces, so neither side can peek at the opponent's pick or steer a roll. Committed rounds persist before resolution renders (the GDD's anti-scum rule). At match end, one Cloud Code call replays the committed log, verifies it against both players' commitments, and writes the ladder — server-only. Timeouts and disconnects follow the PvP page: autoplay AI picks on expiry, five consecutive AI rounds forfeits.

ROUND LOOP — peer-to-peer over Relay, ≤30 rounds Player 1 Player 2 commit h₁ = H(move₁‖n₁) blind — nothing readable commit h₂ = H(move₂‖n₂) blind — nothing readable reveal → verify hashes → resolve round seed = H(n₁‖n₂‖round#) · persisted pre-render timeout: 30 s round + 90 s bank spent → autoplay AI picks · 5 straight AI rounds → forfeit MATCH END — one Cloud Code call: replay committed log vs commitments verified → write ladder (server-only) · mismatch → flag both, no write ~1–2 server calls per match instead of ~70 with per-round server resolution — ≈ $13k/mo saved at 500k CCU, identical integrity: no peeking, no roll-steering, no forged outcomes.

Fig. 7 — Commit-reveal PvP with single end-of-match verification.

Steam market & farm accounts

The mint is downstream of validation: a Cloud Code transaction confirms the asset was legitimately earned or paid for, then calls Steam GenerateItem with the publisher key and binds the instance ID back to Cloud Save. A faked balance never reaches the mint — the only thing touching the real-money market is the one thing that cannot be spoofed. Every trade path goes through a server-side transfer lock with a single-ownership check (in place before the marketplace opens, per GDD critical risks), and every item class carries a config-level tradeable flag so a compromised category can be frozen without a patch.

With client edits neutralized, the residual economic threat is farm accounts — F2P + legal 24/7 autoplay + a real-money market invites item-printing at account scale. Every farm account plays "legitimately," so this is fought with gates and telemetry, not client tech:

GateEffectCost to us
Steam limited accountsAccounts that never spent $5 cannot use the Community Market or trade at all — Valve's ruleFree
Grade-5+ trade gateAlready in the GDD — sub-Epic output is unsellableFree
Per-account market unlockFloor / character-level threshold before listing — a time tax per farm accountConfig
Faucet & listing telemetryPer-account faucet rate vs design curve, listing velocity, multi-account clustering → flag queueAnalytics only
Market kill-switchFreeze all trading by config while investigating an incidentConfig

The farm view (S1 Steam market, 2026-11-03)

The market is built as a mirror (see system-integrity): an export takes the row out of the save and mints a Steam item, an import consumes the Steam item and puts the row back. Every committed export and import is logged twice — in the player’s object (market_ledger, the atomic half) and in the World object (market_world, one row per account per act). GET /api/ops/market/exports?steamid=&day= (ops key) reads the World rows and answers farm: per (steamid, day) the exports and imports, sorted by exports — a farm is a steamid at the daily cap every day. The gates this page asked for: per-account export cap (MARKET_EXPORTS_PER_DAY, 5), account age (MARKET_MIN_SESSION_DAYS, 7), the kill-switch (MARKET_ENABLED unset → every market door 404; the ops view stays readable). Steam’s own $5 limited-account rule applies before ours. Not built: the progression-gated unlock and listing telemetry (config / analytics rows above).

What other games teach

CaseWhat happenedRule we take
Authoritative-server model (industry standard; e.g. Clash of Clans)Client sends inputs/commands, server runs the game and returns results — a hacked client's local values are fiction. CoC's client is so purely a view that full private-server reimplementations exist; modding the client against the real server yields nothing.Exactly this page's model. Events in, computed state out.
Diablo 3 gold dupe (2013)A patch bug in the auction house — not a client hack — let players duplicate gold into the trillions. Blizzard took the AH offline, audited every transaction since the patch, banned the 415 accounts that exploited it, recovered ~85% of the gold, and chose targeted removal over a rollback.The transaction layer is the attack surface. Keep a per-account transaction audit trail from day one, keep a market kill-switch, and design so surgical removal is possible — a live-economy rollback is not a real option.
CS:GO keys (2019)Worldwide fraud networks laundered stolen-card money through tradeable keys; Valve concluded nearly all marketplace key purchases were fraud-sourced and made new keys permanently non-tradeable.Any stable-value tradeable item is a laundering instrument. Keep all liquidity inside the Steam Market (Valve's fraud detection, trade holds, and authenticator friction protect us for free) and keep the per-class tradeable flag revocable by config — Valve's exact lever.
Pattern (TF2 2019 crate bug, Lost Ark 2022 bot waves, RuneScape's botting decades)F2P games with trading get hurt by economy bugs and account farms — almost never by the client-edit vector.Spend the paranoia on transactions and the market. Our autoplay design removes the botting arms race entirely: grinding automation is a feature, so "bot detection" reduces to the rate cap + farm-account gates above.

Live-service cost model

UGS pay-as-you-go unit prices (July 2026): Cloud Code $1.50/M invocations + $0.00002 per compute-second (1M invocations + 20 compute-hours free/mo); Cloud Save $2.50/M writes, $0.90/M reads, $0.50/GiB stored (1M + 1M + 5 GiB free); egress $0.25/GiB; Relay $0.16 per average monthly CCU after the first 50; Leaderboards currently "free for a limited time".

Load model: autoplay per the GDD; flush every 20 min + ~1 immediate event/h ⇒ 4 Cloud Code invocations/h/CCU, each 1 consolidated-doc read + 1 write, ~50 ms compute, ~1 KB delta response; CCU treated as sustained 24/7 (autoplay worst case, 730 h/mo); PvP = 2% of CCU, commit-reveal, 2 verify calls/match, Relay transport; storage 50 KB per stored account.

Sustained CCUCloud CodeCloud Save R+WEgressPvPStorageTotal / mo
~300free tierfree tierfree~$1$0~$0
1,000~$5~$7~$0.5~$5~$0~$15–20
10,000~$73~$99~$7~$50~$5~$230–260
100,000~$730~$990~$70~$500~$50~$2.3–2.6k
500,000~$3,650~$4,960~$350~$2,400~$250~$12–15k

≈ $0.02–0.03 per CCU per month. Cheap because the client simulates and the server only validates — this is a key-value store plus arithmetic, not game servers. Contrast the naive build (per-battle invocations, per-field writes, currencies on the UGS Economy service): it computes to $450k+/mo at 500k CCU, dominated by Economy's $35/M writes. The batched, consolidated, Cloud-Save-ledger design is a ~30× reduction.

LeverRule
Currencies on Cloud Save, never EconomyEconomy writes cost 14× Cloud Save writes ($35/M vs $2.50/M) — at 500k CCU the difference is ~$47k/mo. Ledger by Cloud Code on Protected keys.
Time-based flush, never count-basedBattle rate varies ~10× by floor (one-shots); a count trigger would multiply the entire bill during low-floor farming. A 20-min flush carries 30–100 events for the same price as 10.
Flush window = linear dial20 → 60 min roughly halves the core bill (~$7–8k at 500k) at the cost of a longer batched-tier loss window on crash.
One consolidated document per realmEvery extra key per flush is another billed write.
Return deltas, not snapshotsUnused response bytes bill as egress.
Replay only where it pays100% of value-gating events, ~1% random sample — a 3–5-turn replay is sub-millisecond compute; the budget concern is invocation shape, not CPU.
Store hashes, not logsChain head server-side, full logs client-side, fetched on audit — storage ≈ 0.
UGS Analytics stays OFFMAU-metered — at the ~3–5M MAU that 500k CCU implies, roughly another $9–10k/mo. Use free telemetry.
Leaderboards = unpriced risk"Free for a limited time." Our write pattern (personal bests + post-match only) keeps exposure minimal whatever they price.
Card on file before launchExceeding any UGS free tier disables ALL UGS APIs and config changes; 30 days unpaid disables services. A usage spike must not hard-lock the backend.
Migration kept possible, not planned500k CCU ≈ ~550 req/s average — self-hosting (~$2–4k/mo hardware) would undercut UGS but buys a solo dev an ops burden $12k/mo does not justify against F2P revenue at that scale. Keep the Cloud Code layer behind a thin interface; revisit only if per-op bills materially outpace revenue.

Denial-of-wallet — the per-invocation CPU ceiling

The account runs on Workers Paid plus R2 Paid. That matters more than it sounds: the Workers Free plan stops serving at 100,000 requests/day, which is a genuine hard stop, but the Paid plan has no daily request cap and no platform spend cap of any kind. Cloudflare offers spend limits on AI Gateway and nowhere else. There is therefore no setting on the platform that makes the account bankruptcy-proof; the only platform-enforced ceiling that exists is the per-invocation CPU limit, and Cloudflare documents it explicitly as the control for runaway bills and denial-of-wallet attacks.

It is set to 500 ms in wrangler.jsonc on both branches, replacing the 30,000 ms default — a 60× reduction in what any single request, hostile or looping, can burn. It must live in the config file: a limit set through the dashboard or the settings API is wiped by the next wrangler deploy.

Measured on dfmc-clientCPU time
P500.8 ms
P751.7 ms
P9915.1 ms
P99959.2 ms
True max, 169 hours156 ms

7 days, 293,918 real requests. Percentiles are the wrong instrument here and nearly caused a wrong ceiling. P999 reads 59 ms, but the cron fires every minute and the R2 mirror runs nightly, so seven nightly runs are 0.002% of the request population — far below the P999 cut and completely invisible to it. Querying max cpuTime per hour instead put the true worst case at 156 ms and the top twelve hours all between 107 and 156 ms. Real headroom is 3.2×, not the 8× the percentile implied. When sizing any ceiling against work that runs on a schedule rather than per request, take the max, never a quantile.

Standing re-measure trigger: cron work scales with account count, so 500 ms is correct for the current alpha and is not permanent. Re-run the max-cpuTime query and raise the ceiling before the player base passes a few hundred, or the nightly mirror will begin hitting the cap and failing quietly.

What does not protect the account, and why: a prepaid or low-limit payment card protects nothing during an incident. Cloudflare bills monthly in arrears, so the debt is fully accrued before the card is ever presented; the limit only forces a failed charge and a suspension in the following cycle. Per-IP rate limiting is likewise the wrong key for this game — mobile players share carrier IPs, so an IP rule punishes innocents while a distributed attacker walks through it. Any real allowance must be keyed on steamid.

Still open, and not fixed by any of the above: a tiered spend kill switch counting in our own code (Cloudflare's billing figures arrive far too late to stop an incident that does its damage in hours, and the counter must batch in memory or the counter becomes the bill); a per-account allowance on requests, saves and rewards; and the standing /api/battle hole, which still accepts client-claimed kills and floor with no cross-check against the player's server-side maxFloor or the floor's real rarity mix. Note that observability.logs runs at 100% sampling with persist on, which is a metered cost that tracks an attack linearly; the decision to thin it to ~10% is parked until the alpha grows.

The write breaker and the sliced retention

The breaker. Every Durable Object request is measured: the sql.exec wrapper accumulates billed rowsWritten across the request, and wrBreaker checks three layers — per request (ceiling 2,000 rows, ~40× the heaviest legitimate save), per hour (20,000 rows per object, announced once each at 70/90/100% as wr_budget), and each closed hour against the object's own history (wr_rate_anomaly). It detects and alerts, never refuses an authoritative write; a per-request trip files wr_request_runaway into the auto-report inbox. The one write it may refuse is diagnostic: once an object passes 100% of its hour, flag() stops inserting anomalies rows until the hour turns (the report itself still goes out); saves, battle credits, grants and wallet moves are never consulted against it. It reached release with the 0.56-era server pushes. Layer 3 judges the CLOSED hour (since 2026-09-02): hour totals live in memory and are flushed to wr_hours (168 hours kept) only when the first request of the next hour arrives; at that moment the hour that just ended is compared to 10× the p90 of the retained hours that saw writes, and only if at least 12 such hours exist — an object with fewer active hours cannot trip it. It therefore fires on the next hour's first request, and an hour an evicted object never closed is not judged. The row names the closed hour, its rows and requests, the object class, the p90 and the number of hours it was drawn from.

Report #458 (2026-08-21), the breaker's first live morning. One battle request wrote 2,193 rows. Cause: the battle credit loop ran two retention deletes per event, ungated — the 7-day prune of events_seen (the replay-protection record) and the 30-day prune of drops_granted. Steady state deleted a trickle; ~7 days after a heavy day, one battle inherited the whole day's expiry in a single statement. 2026-08-21 09:15 was the 7-day echo of Aug 14, the heaviest battle day on record (6,453 requests). Bursts of 3,650 and 3,037 rows on Aug 16–17 predate the breaker and went unflagged — the burst was routine, the tripwire was new. The same delete was also a full-table scan per event (events_seen carries no ts index by design).

The fix (D order 2026-08-21, LIVE on release since 2026-08-21 (0.58)). Every retention prune in the player object now lives in maybeMaintain, the existing once-an-hour save-door gate: the two battle-loop deletes joined the three prunes already there (anomalies 30d, expired sessions, matches 14d), and all five drain through one shared budget of billed rows per run (900, per-statement LIMIT capped at 450, spent from the cursor's own rowsWritten) — so cleanup can never again resemble a runaway, and the per-event scan leaves the hot path. Nothing younger than its cutoff is ever touched: a backlog drain only makes retention run longer, never shorter, so replay protection is unaffected. A backlog clears at up to ~24 budgets/day per object; the heaviest measured day fits with a wide margin. Each run prints one MAINT console line with per-table counts and an error count — the reader that keeps a silently-thrown prune from being the built-with-no-reader disease; a lazily-created table that does not exist yet counts as pruned 0, not as an error.

Worker: maybeMaintain and the battle credit loop in src/worker.js. Probe: tools/probe_battleprune.py 6/6 through the real worker's public doors, red 3/6 against the build it replaces (no MAINT line; battle at 28 statements against 27). Re-run green: spawnclock 6/6, dismcall 11/11, dismwindow 10/10, ledgerrace 13/13, advclaim 9/9, boonclock 7/7.

v2 corrections changelog

#Correction (July 2026 review)
1Reversed the v1 recommendation to use UGS Economy for currency ledgers — 14× write cost. Currencies live on Cloud Save Protected keys.
2Added the concurrency contract: idempotency keys on every request + Cloud Save writeLock optimistic versioning. Mandatory before trading exists.
3Replaced the flat battles/hour cap with turn-count-derived time floors (variable battle speed — floor-1 one-shots vs endgame fights), Remote-Config-tuned, sharing the economy sheet's pacing curve.
4Added the farm-account gate set (Steam $5 limit, grade-5 gate, per-account market unlock, faucet/listing telemetry, kill-switch) as the primary residual-threat defense.
5PvP moved from per-round server resolution to commit-reveal over Relay with a single end-of-match verification (~$13k/mo saved at 500k CCU, same integrity).
6Event logs hash-chained; server stores chain heads only, full logs fetched on audit.

Implementation order

This spec is roadmap step #16 — Backend authority layer (Simplified Static). Groundwork lands earlier: the save-skeleton key schema with #1 Data architecture, and seed + move-log persistence with the #6/#10 battle system (replay verification needs them). Step #16 delivers Auth, access classes, the transaction contract, reward validation, the rate cap, and idempotency + writeLock; then #17 Realms & death consumes the atomic death pipeline, #18 PvP the commit-reveal verifier, and #19 Steam integration the mint + transfer lock. Open decisions: publisher-key host (Cloud Code direct vs thin Lambda), Soul Gem tradeability, death-marked creature tradeability, and whether settings mirror to UGS for the future mobile port (Steam Cloud is PC-only).

Traffic blocks — the rate limiter's second layer

Every authenticated /api/* request is sorted into a rate class and spends a token from a per-account bucket for that class. The classes and their buckets (src/worker.js, RATE): battle 24 / 4 per min, floor 30 / 8, save 20 / 12, save_read 60 / 60, me 60 / 60, name 3 / 0.05, auth 10 / 10, db 80 / 40, mm 60 / 45 (/api/pvp/mm/), pvpm 120 / 90 (/api/db/m/), wallet 24 / 40, roll 300 / 240 (/api/roll, /api/hatch, /api/breed/claim), other 40 / 24, plus a blanket ip_all of 600 / 360 per address, checked after the session gate and the per-account bucket (see system-networking). An empty bucket answers 429 {error:'rate'}.

wallet exists because a generic bucket punished a normal player (2026-08-04). /api/wallet/spend and /api/shop/buy matched no prefix, so they fell into other and shared its 20 tokens with hatching, breeding, feedback and the shop catalogue. A player mid-session has only a handful left, so a couple of quick clicks on the inventory + button returned 429 — shown to the player as PURCHASE FAILED with no reason, and, worse, passed to reportBreach, which files that account into the traffic table. Mashing a buy button looked like abuse in our own anticheat record.

These calls spend the player's own currency, are server-validated and carry an idempotency key, so repeating one mints nothing: the limit is there for server cost, not for economy abuse. The class is therefore sized so a legitimate player cannot reach it — the client paces purchases at one per five seconds behind a confirmation gate (12/min) while the bucket refills at 20/min with a burst of 12 for someone buying several slot tiers after topping up. Measured: mashing from a full bucket is refused on the 13th call, while thirty purchases at the client's pace never take the bucket below 11 tokens. The client is the pacer; the bucket is only the backstop. A rate class that a normal player can reach is a bug report waiting to be filed against the wrong subsystem.

A refusal also escalates. reportBreach posts the (account, class) pair to the WorldDO at most once per RATE_FLAG_MS (default 60s); once TRAFFIC_BLOCK_N reports (50 in the deployed wrangler.jsonc vars; code default 3 where the var is unset) land inside a rolling 10 minutes, traffic_status is set blocked=1 for that pair and an anomalies row is written. A blocked pair is refused at the gate with 403 {error:'soft_block'} before any Durable Object wakes. The worker caches the block set per isolate for SB_TTL_MS (default 60s), so a change takes up to a minute to be felt.

Reads are exempt (2026-08-02). Classes listed in RATE_READ_ONLY — currently save_read (GET /api/save) and, since 2026-08-06, me (/api/me) — are never soft-blocked and never escalate. Reading your own save grants nothing, so blocking it prevents no abuse; and because the shell RETRIES that read when it fails, escalation on it meant the retry loop fed the counter that produced the block. Nothing may join that set which can mutate state or mint a value.

Blocks expire (2026-08-02). /api/_internal/softblocks clears blocked=1 rows older than SB_EXPIRE_MS (default 1 hour) each time the set is read. The row survives with blocked=0, so /api/admin/traffic history and the traffic audit table are unaffected, and a genuine abuser is re-blocked by the next 10-minute window. SB_EXPIRE_MS=0 restores permanent blocks. Before this, blocks were permanent and cleared only by hand — the flag detail said “queued for review” and there was no queue.

Failure mode this class of block produces, and how to recognise it. A soft block is per (account, class), so the account keeps working everywhere else. With save blocked, GET /api/me still answers 200 (class other) while GET /api/save answers 403 forever. The shell reads that as a failed sync and falls back to the sign-in gate, so the player sees “Could not load your save — retry N…” under a Steam button and reports it as “I cannot log in”. It is not a login failure and re-authenticating cannot help. Diagnose with the account's own cookie: curl -i https://playdfmc.com/api/save -H 'cookie: ht_session=…' — 403 is a block, 429 is the bucket, 500 is the object.

DoorAnswers
GET /api/admin/trafficEvery currently blocked (account, class) pair with the timestamp it was set, plus the last 24h of breach reports grouped by pair. Admin session only (ADMIN_STEAMIDS), routed to the WorldDO.
POST /api/admin/traffic/clear{sid, cls} — deletes the block AND that pair's breach history, so the count starts from zero rather than re-tripping on the next refusal. This is the unblock.
POST /api/admin/traffic/block{sid, cls} — manual block, for when the automatic threshold is not what you want to rely on.
GET /api/admin/flagsWorldDO anomalies, newest first, which is where traffic_block rows land with their cls xN/10min detail. Optional sid and kind filters; limit defaults to 200, max 500. Answers {ok, rows, counts24h} (an object, not a bare array).

Dismantle yield — the shared table

src/dismantle.js is a pure module carrying the dismantle yield: dismYield, breedRates, gradeNum, buildDismantleDb, dismTotals. Same contract as src/power.js — no fetch, no timers, no Durable Object state, safe to import from the Worker and from node, with CSV fetching left to the caller Worker-side and never inside a DO.

Why it had to exist. POST /api/backup/dismantle used to remove rows and credit nothing, because the yield lived only in play/app.js, which made the ops door unusable on a real player: it would delete a pile and pay for none of it. Since #A4 (D 2026-09-28, on dev): the Worker prices every removed row with dismYield (forwarded to the object, which never fetches the CSV) and the object credits it through dismCreditRow — the same priced-credit step /api/dismantle uses, on the same ledger keys, so a uid paid by one door can never be paid again by the other. The door fails closed on a pricing outage, refuses socketed or equipped uids (held-in-socket), still spares Mutants, and replays a retried apply by its idem instead of crediting twice. Separately — corrected 2026-08-20, this sentence had gone stale — foodCeil now carries a priced dismantle allowance: the worker prices each declared row and the player’s object verifies it against its own previous save or the item’s grant record before a point of allowance is spent (system-networking owns the mechanics; the accepted grant origins were widened to every stamp the server writes on 2026-08-20).

Admin grants are sourced from birth (D ruling 2026-08-20, RELEASED IN 0.56). mintGrant assigns server uids to the payload, books each item into items with origin grant, and every fresh grant writes a permanent acct_log entry through the audited kind admin_grant — gid, reason and item list. A granted item is checkable and dismantles like anything else; a merely claimed grant reads unsourced and stays refused. Replays log nothing; the original entry is the record.

The yield is AUTHORED, not computed. dismantle.csv carries one row per (Category, Grade, Tier) and the module only looks it up; the gear rows keep a fourth key field fixed at +0 (a legacy refinement slot — the mechanic does not exist, and the 1,000 +1…+10 rows were removed 2026-08-25). The ladders are deliberately not smooth; there is no formula to derive and none should be written.

Key shapeApplies to
Gear|<Grade>|+0|<Tier 1-10>Weapon, Armour, Accessory — blank Tier defaults to 1; the +0 field is constant
<Cat>|<Grade>|<Tier>Gem, Rune, Scroll, Tag — Tier carried verbatim, not defaulted
Key|Worn|<round(floor/10)>Boss keys, on the tier ladder
Material|Worn|<Tier>Materials
— (no table lookup)Egg and Monster: breeding.csv Egg_Dismantle_Ichor at the item's grade. A creature salvages for the same Ichor as an egg of its grade (D ruling 2026-07-24).

Mutants (D order 2026-08-21, LIVE on release since 2026-08-21 (0.58)). The CLIENT’s dismYield refuses a Mutant creature — mutants are never dismantled — and /api/_internal/bulk_dismantle carries its own copy of the refusal: with scope 'all' it now leaves any Monster with mut:true untouched, because it was the one dismantle running server-side without the client’s gate. This module deliberately does not copy the refusal: the pricing copy must keep verifying and paying a stale release client’s Mutant claim (additive-only backend rule), and refusing here would protect nothing — the destruction is client-side. The parity gate pins the asymmetry (B4) so it cannot be “fixed” by porting the refusal in.

null is not zero. Class souls, anything flagged dismantleable:false, an unknown category and a missing CSV row all return null. A caller that reads null as zero would silently confirm a zero-yield dismantle, which is how a player loses a pile for nothing.

The parity gate. A transcription is worthless unless something proves it has not drifted, so tools/probe_dismantle_parity.py boots the real client and compares against it: the rebuilt table key-for-key and value-for-value, then dismYield on a fixture generated from every authored row plus every creature grade plus the null cases, then the aggregate. Fixtures come from the CSV, so a newly authored row is covered the day it lands. Currently 10/10 over 1,541 fixtures from 1,515 rows, including gate B4, which asserts the one deliberate divergence: the client refuses a Mutant and this module keeps paying it.

Not yet enforcing. The module exists and is proven; nothing validates against it yet. The design (D): the player dismantles, the client credits immediately — safe, because a dismantle carries no RNG — the save rides the next sync, and the server recomputes and validates by diffing the previous save against the new one. Items gone from the bag are what was dismantled. A client receipt is never trusted, because a receipt is precisely what an attacker writes.

Server: src/dismantle.js. Gate: tools/probe_dismantle_parity.py. Client source of truth transcribed from play/app.js dismYield/breedRates/gradeNum/dismantleDb.

Why no automatic report ever arrived — and how one does now

Status: LIVE on release since 2026-08-13 (0.48). Superseded in part by the 2026-08-17 batch below, which is on release. Between 2026-08-04 and 2026-08-12 the automatic reporter filed zero readable rows. D noticed it before any tool did (report #124: “I haven't seen one of them yet which is odd”). There were two independent causes and either one alone was fatal, which is why neither showed up as an error: nothing failed, the rows were written exactly as instructed, and the instructions were wrong.

  1. The status word. The row was written status='open'. Every reader filters status='new' — /api/feedback/export defaults to it and /api/admin/feedback hardcodes it. Worse, 'open' is not in the accepted set of the mark endpoints (['new','processed','wontfix','fixed']), so the row could neither be listed nor closed. It was unreachable in both directions.
  2. The object. This is the load-bearing one, and it is the same defect class as the PvP queue door's 401. src/worker.js defines ONE class: PlayerDO and WorldDO both extends MainDO with empty bodies, so every table exists in both objects and which one holds the truth is decided by routing, not by schema. WORLD_PATHS sends /api/feedback and /api/admin/ to the WorldDO, so the inbox everyone reads is the WorldDO's feedback table. But every REPORTABLE kind fires on a save, battle or roll path, and those are not world paths — so flag() → flagReport() ran inside the player's own object and inserted into that object's feedback table. Same schema. Different object. Read by nothing.

The proof was in the live data, not in the reasoning. The export's counts (GROUP BY kind, status) carried no auto kind at any status after weeks of guards firing, and /api/admin/save-history returned [] for an account on save version 3,525 — because that door reads the WorldDO's save_hist too.

The relay — how the row crosses objects without a fetch

The raising object must never call fetch(): an outbound subrequest is one of the lifecycle facts that stops a Durable Object hibernating, and a non-hibernateable object bills at full wall-clock rate — a ~1000× cost difference. So the row is carried out the way ANTICHEAT 2 already carries a leaderboard write:

  1. flagReport() queues onto this._autoQ instead of inserting. The per-account-per-kind-per-day dedup row stays where it is raised, because that is where the account's own history lives.
  2. MainDO.fetch is now a thin wrapper over fetchInner that attaches the queue to the response as the x-ht-autorept header. A wrapper, not a body annotation, because a flag can be raised from any of ~180 return sites and a header needs none of them changed — and a header carries a streamed or non-JSON response unaltered.
  3. autoRelay() in the Worker strips the header (it never reaches the browser: it names accounts and internal guard kinds) and POSTs to /api/_internal/autorept on the WorldDO under ctx.waitUntil. The player's save is never delayed by a bug report, and a lost relay costs the report rather than the request it rode on. Applied at the Worker's own fetch, one place, because a flag can be raised on any route — world or player.
  4. The world door writes the row with status='new' and enforces the forty-a-day inbox ceiling there, because the budget is a property of the inbox: forty accounts each filing their own one-a-day would otherwise bury every real report between them.

Still true after this change: raw anomalies rows are still written into the raising object and /api/admin/flags still reads only the WorldDO's, so it still shows only world-path events. The row behind a report stays per-account and is read through GET /api/backup/flags?key=&sid=. The “first detail of the day” wording above is superseded by the 2026-08-17 batch — a report now carries the day's running count and the latest detail, updated in place.

Every guard reports itself — and admin actions leave the inbox (2026-08-17)

Status: LIVE on release since 2026-08-21 (0.58). The reporter above shipped with an allowlist of 19 kinds. The worker raises 74. The other 55 wrote an anomalies row and stopped there, so “show me the bug reports” showed roughly a quarter of what the server had caught — and the missing three quarters included every kind that matters most for trust: wins_over_ledger, kills_over_ledger, lb_power_over, maxfloor_over, floornum_over, battle_floor_over, boss_overdraft, inv_illegal, breed_claim_bad, unlock_sequence, wallet_drift, wallet_rate, wraithgold_seed and pvp_commit_mismatch. The same list also carried wraithgold_over_grant, which nothing has ever raised — the same disease pointing the other way, unnoticed for exactly as long.

D's ruling, 2026-08-17: “That noise is not normal. Those are bugs.” So there is no allowlist, no routine class and no exclusion list. The four kinds a previous session excluded on purpose — grow_only, dead_slot, rate_cap and the save-size pair — report like everything else, because a guard firing 237 times on one honest account is a defect to be explained, not background. The sequence is deliberate and it is the whole design: report everything → watch live accounts → only a kind that never fires honestly becomes enforcement. Nothing here gates anything today.

The permanent account action log (2026-08-17)

Status: LIVE on release since 2026-08-21 (0.58). D's ruling: admin and ops actions “are legal actions and they need to be permanently recorded on each account when they are taken”, and “Ops only” — players never see these entries.

They were not being recorded permanently, and some history is already gone. Every one of them wrote an anomalies row and nothing else, and maybeMaintain deletes anomalies at 30 days. Every wipe, rollback, bulk dismantle and restore older than a month had already expired before this was written.

Worker: AUDIT_KINDS/FLAG_CLASS/FLAG_WHY, flagReport(), auditLog(), ensureAcctLog(), ensureAutoIdx(), budgetDrop(), MainDO.fetch, /api/_internal/autorept, /api/admin/acctlog in src/worker.js. Probe: tools/probe_flagrept.mjs — BEFORE 12/12 on the build it replaces, AFTER 25/25, methods lifted verbatim and run against real SQLite. That probe had been crashing since 2026-08-12: it asserted flagReport() inserted a row, and the relay above moved the insert to the world door, so every run since threw on rows()[0].status and nothing ran it. It is the fourth gate in this repo found retired the same way (probe_savefacade 14/17, probe_floorclock_boon 2/9, probe_spawnshare crashing). A lift list is a dependency list.

Worker: flagReport(), MainDO.fetch/fetchInner, /api/_internal/autorept, autoRelay() in src/worker.js. Probe: tools/probe_autorept.mjs 18/18 — 7/18 against the build it replaces. The probe drives the real worker under wrangler dev --local and asserts against the same export door the feedback pull uses; it re-implements none of the logic it tests.

Changelog

Ops reads — the account's own record

Both are key-gated with the ops key and are read-only: they select, they never write. They exist because /api/admin/* is dispatched to the WorldDO while saves, anomalies and progress live in the player's own object, so the admin console's save-history and flags views have always answered empty for player data. These doors are what made the 2026-07-31 rollback repair possible. Since 2026-09-30 (F225, dev) the three per-account admin ops are routed to the target account's own object: lock, save-history and rollback — through the GM console (/api/gm/admin/*) or a Steam admin session (/api/admin/*) — reach the player's PlayerDO via adminPlayerOp (_internal/save_hist, _internal/rollback, _internal/lock). Lock also keeps its WorldDO row, so world-routed writes stay refused and the admin_lock audit row keeps the actor. Since 2026-10-23 (merge-follow, dev) every one of these doors resolves a merged-away id to its winner through the World ledger first and answers both ids.

DoorAnswers
GET /api/backup/history?key=&sid=&realm=The version ring for that account — ver, ts and size for the last 20 saves; add full=1 for the payloads. Gaps in it are diagnostic: a player who is clearly playing while no version lands is a client that has stopped uploading.
GET /api/backup/facts?key=&sid=The server's own record for the account: the progress ratchet per realm and the ledger it built from credited battles (xp, food, kills, battles). This is the honest basis for any repair — it is what the server credited, not what a client claimed.
Repair discipline. Restore only what the server can prove. The ratchet proves floors; the ledger's xp proves character xp because xp is earned and never spent. It does not prove currency: food_total is cumulative earned, and a player has spent some of it, so restoring a wallet from the ledger mints money. Never send a placeholder payload to a live account to test the door — /api/backup/restore sets the ratchet from data.maxFloor, so a stub body resets the account's climb to 1.

Restore is not a save. /api/backup/restore writes through writeSave — the one write path since 38a310d, which writes the saves row, the save_parts and the parts mark together — plus the progress ratchet, and it stamps __wipe_epoch in kv. It does not add a save_hist version, so a restored version is invisible to GET /api/backup/history: the ring can top out one version below what export&live=1 returns, and that gap is the restore, not a lost write. (The older warning that a restore would install a save no part reflects no longer holds: restore writes the parts.)

What a bug report actually carries

Status: LIVE on release since 2026-08-05 (0.33). A report is one row in the feedback table of the shared object — the same SQLite as every save, capped at 10 GB. Measured across all 33 stored reports on 2026-08-05, the payload was:

FieldShareAverageWhat it is
shot79.2%50,066 BREMOVED 2026-08-05. Base64 JPEG, inline.
logs19.9%12,576 BBrowser errors plus the recent-activity ring — up to ~130 activity lines and ~30 error lines, capped at 30,000 chars server-side and ~24,000 client-side.
ctx0.4%275 Bbuild, floor, phase, mode, playerLv, ichor, invCount, invCap, screen, viewport, dataWarns, saveTs, saveAt, plus openedAt, typedMs and atSend (see below). Capped at 4,000 chars.
body0.2%120 BWhat the player typed. Capped at 4,000.
ua0.2%110 BUser agent, server-stamped, capped at 300.
title<0.1%18 BCapped at 200.

With the screenshot gone a report is 13.1 KB instead of 63 KB, and logs are 96% of what remains — they are now the report. Guards unchanged: 20 reports per account per hour, 300 globally per hour, and a same-account/same-wording/same-hour dedupe that hands back the existing id.

The report is anchored to the moment it was OPENED

Status: LIVE on release since 2026-08-05 (0.33). ctx used to be read at send time — after the player had typed. With autoplay running that is several battles and possibly a floor or two later, so the stamp described a moment the report was not about. Report #29 names the floor-map key count and arrives stamped screen: resolving for exactly this reason.

The reporter deliberately does not close the inventory or equipment window behind it, so the open-time screen is the one the player was actually looking at when they decided to file. Verified by tools/probe_noshot.py (7/7), which moves the run to floor 77 and opens the inventory underneath an open reporter, then asserts ctx.floor still reads the open-time floor and that atSend records the drift — the #29 condition, reproduced deliberately.

Still not perfect, and worth knowing: opening the reporter goes through the menu, so the anchor is the moment the player decided to report, not the moment the bug happened. The activity log timestamps in logs remain the only thing that ties a report to the event itself.

Why the screenshot went (D order, 2026-08-05)

All 33 stored reports were decoded and inspected before the removal, not sampled. 24 carried a usable frame, 3 were flat repeating texture with no information (one of them 152 KB, filed against a report titled “font too small”), and 6 had nothing at all. A first pass using luminance-histogram spread scored the texture images as fine; averaging each frame down to 32×32 so high-frequency pattern cancels is the test that catches them, and that correction is recorded here because the first answer given to D was wrong.

What changed in the build. Client: captureShot() and the cdnjs fetch are gone, the “Include a screenshot of this screen” toggle is gone, and the panel no longer hides itself before sending. Worker: b.shot is ignored, not rejected — release clients keep sending one until the next release and a 413 would lose their whole report; the export and the admin summary no longer select the column; and purgeShots() blanks every stored image once, guarded by a wmeta flag and hung on the key-gated export door so no player request pays for it. The column itself stays, because dropping a column in SQLite is a table rebuild.

Not a cost fix. The whole stored payload was 1.65 MB — about $0.0003 a month. It was removed because it was not earning its place, and the storage argument that was first offered for it was wrong.

Client: openFeedback(), sendFeedback(), feedbackLogs(), feedbackCtx() in play/app.js. Worker: the /api/feedback POST handler, /api/feedback/export, purgeShots() in src/worker.js. Probe: tools/probe_noshot.py 5/5.

GM console — the operator surface at /gm

LIVE on release since 2026-08-23 (0.61). A browser console for operators, served inline by the Worker at /gm (deliberately not under play/), reachable even under maintenance mode. Its auth is fully independent of player sessions.

Ops doors — the complete set

Every /api/backup/* door is gated on the ops key at the Worker's session gate, which matches on the stripped path so /dev/api/backup/* is gated identically. A wrong key answers 403 forbidden and never wakes an object. Note /api/feedback/export takes k, not key. Per-account doors act on realm standard whatever realm is passed (one game mode). The first table is the read doors; the write doors follow it.

DoorAnswers
GET /api/backup/accounts?key=&after=&limit=[&now=]The roster — every steamid the world object knows, paginated. Durable Objects are addressed by steamid, so without this there is no way to enumerate who exists. Every page also carries wdel {n, due24h} (since 2026-11-02): the World's pending-deletion list — how many rows, and the ids whose window runs out within 24 h of now (the clock, or ?now= as the delete sweep takes it) — the one ops read of wdel.
GET /api/backup/flags?key=&sid=&kind=&n=Anomalies recorded against the account, plus a histogram by kind.
GET /api/backup/inv?key=&sid=Server-held item rows and tombstones (realm is fixed to standard; a realm param is ignored).
GET /api/backup/drops?key=&sid=drops_granted: what the server handed out per battle event (pruned at 7 days); optional from/to.
GET /api/backup/facts?key=&sid=The server's own record for the account: the ratchet it credited and the xp/Ichor/kills/battles ledger built from credited battles.
GET /api/backup/history?key=&sid=The save_hist ring (time-spaced; see system-networking).
GET /api/backup/export?key=&sid=[&live=1]The stored save; live=1 asks the player's own object instead of the R2 mirror.
GET /api/backup/list?key=&sid=The account's R2 mirror slots.
GET /api/backup/pvpq?key=The PvP matchmaking queue (without end; see the write doors).
GET /api/backup/breedfix?sid=&realm= (dry) / POST ...&apply=1The Worn-pen repair (2026-08-22): without apply a read-only plan of every verified egg draw recorded on a Worn pair that was not an honest Worn pair; with it, the repair itself (see the changelog entry). Dry by default; a write door only with apply=1.
GET /api/backup/parts?key=&sid=&realm=Split save parts and rejoin_ok — the stage-2 gate for the save split.
GET /api/backup/gmsnap?k=The cached GM snapshot — the same value /api/gm/snapshot serves, without a GM session.
GET /api/backup/advlog/list?k=&round=Steamids that have uploaded for an adventure-log round, paginated by cursor.
GET /api/backup/advlog/get?k=&round=&sid=One account’s uploaded adventure-log file for the round.

Write doors. Each of these changes stored state; the migration and repair doors are dry by default and write only with apply=1.

DoorWrites
POST /api/backup/restore[&force=1], /api/backup/recoverInstall a save on an account (from a body, or from the R2 mirror). Since 2026-11-02 (T68) the payload is counted against the server slot caps: more souls or items than the account's cap answers 409 over_cap with both counts (over.soul {n, cap}, over.inv {n, cap}) and stores nothing; force=1 stores it as given and flags ops_restore_over_cap.
POST /api/backup/grant, /grandfather, /revokeMint an admin grant; flip the account's unsourced item rows to origin grandfather; revoke entitlement grants.
POST /api/backup/dismantleBulk dismantle, priced and credited since #A4 (see Dismantle yield above).
GET /api/backup/rollback?sid=&ver= (preview) / POST ...&apply=1Roll the save back to a save_hist version.
GET /api/backup/sockmig, /monmig, /birthmig, /reparts [&apply=1]Socket opening balance, creature ledger rows, birth records, and a rewrite of the split save parts.
GET /api/backup/keymig?sid=&realm=standard (dry) / POST ...&apply=1Legacy boss keys (F131, the 0.88 deploy): mints the server key rows a STORED save's Key items lack, per zone, origin grandfather, never more than the save holds; dry by default, idempotent. tools/keymig_all.py [--apply] runs it over the roster.
POST /api/backup/pvpq?end=<mid>Force-ends a live PvP match.
POST /api/backup/sweepRuns the backup sweep now (the cron's code path).
POST /api/backup/reverseRefund reversal; closed until a store is linked (answers refunds_not_wired).
2026-08-04 — the detected-but-unenforced flag class reports itself (LIVE on release since 2026-08-08 (0.42)). CORRECTION 2026-08-12 — as written below this NEVER WORKED, and the two reasons are recorded in “Why no automatic report ever arrived” further down this page. Read that section before trusting this paragraph. status is now 'new', and the row is relayed to the WorldDO instead of being inserted where it is raised. Fifteen flag kinds now file a bug report into the same inbox as player reports, with kind='auto' and status='open', instead of waiting in the flags door to be found by hand. Written server-side on purpose. The obvious route was the client's automatic report, and that was rejected: sending a server detection back through the client tells the client which guard fired, which is the exact criticism of headOk — computed, flagged, returned to the caller, gating nothing, so the reply announces the catch. The client is told nothing.

It is an allowlist, not everything. Most flags are routine and reporting them would bury the ones that are not. Reported: dismantle_claim_bad, inv_unsourced, party_line_dupe, grow_only_map, maxfloor_regress, wraithgold_over_grant, save_conflict, stale_instance, chain_break, xp_over_ledger, classxp_over_ledger, ichor_over_ledger, boss_floor_anomaly, floor_oob, roll_rate. Deliberately excluded, each for a reason: grow_only (the normal outcome of a grow-only merge, 237 on one account), save_oversize and save_large (the save ceiling, owned by the split), rate_cap (the cap doing its job), and dead_slot (measured benign — a legitimately resumed battle claims a spawn the server already marked dead, so reporting it would report honest players).

Deduped and budgeted. One report per account per kind per day, held on disk rather than in the isolate's memory, because an isolate restart would otherwise file the same report again. Forty automatic rows a day across the whole inbox, because the failure mode of a reporter is a systemic bug filing thousands of rows and burying every real report including its own. The flag row itself is written regardless: the reporter has its own try and can never stop the evidence being recorded.

None of this enforces anything. These guards still gate nothing; what changed is that they now arrive somewhere they will be read.
2026-08-04 — the parts door also answers freshness (LIVE on release since 2026-08-08 (0.42)). It now returns fresh, saves_ver, mark_ver, gen and mark_gen beside n and rejoin_ok. fresh is the stage-3 gate in miniature: the parts are only servable while the stored watermark equals saves.ver and the wipe generation matches. Watch it before the read cutover — once every writer goes through writeSave() a not-fresh account should be rare, and a sustained rate names a writer still bypassing the facade.
GET /api/backup/pvpq?key=The PvP queue: one row per node with age_ms, and a count of how many are pairable right now. Age is the point — the pairing window is 12 s, so a live node and an expired one are indistinguishable until you subtract. Fewer than two pairable nodes means nobody can be matched, and the door says so.
GET /api/backup/export?key=&sid=&realm=&live=1The save. live=1 matters. Without it the read comes from the R2 mirror, which is a nightly copy — a player who started today has no R2 object at all, and reading that absence as “no save” is wrong. The authoritative save is always in the player's own Durable Object, with 20 versions of history, from the first write. R2 is off-site disaster recovery, not the store.
Route placement is load-bearing — this is a real defect class, not a style note. MainDO.fetch carries a blanket session gate partway down its body: if (!ses) return json({error:'unauthenticated'}, 401). Every route defined below that line requires a player session. The Worker calls an internal route with x-internal: 1 and no session cookie, so any /api/_internal/* handler written below the gate is unreachable: ses is null, the object 401s, and the Worker forwards that status verbatim. The ops door then fails with unauthenticated even though the caller's ops key was correct and the door beside it answered.

Invariant: every /api/_internal/* handler lives above the blanket gate. The symptom is diagnostic — an ops door returning 401 unauthenticated rather than 403 forbidden is a placement failure, because a bad key produces 403 at the Worker and never reaches the object at all.