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.
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.
Fig. 1 — Trust boundary and storage topology. Everything that gates loot, rank, trade, or death lives right of the boundary.
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.
| Data | Authority | Store · class | Flush |
|---|---|---|---|
| Currencies (Scrap, Ichor, Food) + materials (Dust, Essence, Ink) | Server | Cloud Save · Protected | Batched; immediate on rare gains |
| Inventory, eggs, creature instances | Server | Cloud Save · Protected | Immediate on craft/grade/hatch/fuse; batched on drops/XP |
| Character, class levels, playerLevel, Codex | Server | Cloud Save · Protected | Batched |
| Floor record / PvP rank | Server | Leaderboards + Cloud Save | Personal best / post-match only |
| Tradeable items & creatures (ownership) | Steam | Steam Inventory Service | Server-minted via publisher key |
| Autoplay config, keybinds, graphics, UI, exported logs | Client | Steam Cloud + local | Best-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.
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.
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 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.
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).
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.
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.
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.
Fig. 5 — Hash-chained event log. Editing, inserting, or deleting any past battle breaks every subsequent hash.
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.
Fig. 6 — Optimistic concurrency + idempotency. 100 gold cannot become 200 gold of spending regardless of request timing.
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.
Fig. 7 — Commit-reveal PvP with single end-of-match verification.
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:
| Gate | Effect | Cost to us |
|---|---|---|
| Steam limited accounts | Accounts that never spent $5 cannot use the Community Market or trade at all — Valve's rule | Free |
| Grade-5+ trade gate | Already in the GDD — sub-Epic output is unsellable | Free |
| Per-account market unlock | Floor / character-level threshold before listing — a time tax per farm account | Config |
| Faucet & listing telemetry | Per-account faucet rate vs design curve, listing velocity, multi-account clustering → flag queue | Analytics only |
| Market kill-switch | Freeze all trading by config while investigating an incident | Config |
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).
| Case | What happened | Rule 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. |
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 CCU | Cloud Code | Cloud Save R+W | Egress | PvP | Storage | Total / mo |
|---|---|---|---|---|---|---|
| ~300 | free tier | free tier | free | ~$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.
| Lever | Rule |
|---|---|
| Currencies on Cloud Save, never Economy | Economy 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-based | Battle 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 dial | 20 → 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 realm | Every extra key per flush is another billed write. |
| Return deltas, not snapshots | Unused response bytes bill as egress. |
| Replay only where it pays | 100% 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 logs | Chain head server-side, full logs client-side, fetched on audit — storage ≈ 0. |
| UGS Analytics stays OFF | MAU-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 launch | Exceeding 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 planned | 500k 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. |
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-client | CPU time |
|---|---|
| P50 | 0.8 ms |
| P75 | 1.7 ms |
| P99 | 15.1 ms |
| P999 | 59.2 ms |
| True max, 169 hours | 156 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 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.
| # | Correction (July 2026 review) |
|---|---|
| 1 | Reversed the v1 recommendation to use UGS Economy for currency ledgers — 14× write cost. Currencies live on Cloud Save Protected keys. |
| 2 | Added the concurrency contract: idempotency keys on every request + Cloud Save writeLock optimistic versioning. Mandatory before trading exists. |
| 3 | Replaced 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. |
| 4 | Added 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. |
| 5 | PvP 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). |
| 6 | Event logs hash-chained; server stores chain heads only, full logs fetched on audit. |
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).
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.
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.
| Door | Answers |
|---|---|
GET /api/admin/traffic | Every 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/flags | WorldDO 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). |
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 shape | Applies 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.
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.
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.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 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:
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.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.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.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.
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.
FLAG_WHY is a courtesy: a kind
with no description still files, and its body says so. FLAG_CLASS is a sort order: an
unclassified kind is system. Neither is a gate. This is the property the allowlist lacked,
and it is why the batch is worth more than the 55 kinds it switched on.suspect — the claim pays
more than the ledger can account for, past any in-flight envelope. sync — two devices,
two windows, or a reply that never landed. system — the build is wrong; the default.
A real bug looks exactly like suspect, which is why the watching comes first.anomalies is not the
counter it looks like — flag() collapses repeats to one row a minute — so
the count is kept in memory and seeded on a cold isolate from the count at the last relay, which is on
disk. A restart can make the number read low, never high.new would
hide that it was ever closed.suspect 400, sync 300,
system 300 a day) and an update in place costs nothing against them. The old ceiling was
40 rows a day for the whole inbox, dropped silently — survivable at 12 reporting
kinds, fatal at 64, because the drops are first-come-first-served and the routine kinds fire first.
A refused report is now itself reported, one row a day counting up: a reporter that loses
reports quietly is the disease this batch exists to cure.autoRelay's parse
throw, which discarded the whole batch with no trace. Rows are now added while they fit, the
remainder stays queued for the next response, and audit rows ride ahead of reports.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.
maybeMaintain and it must stay absent.flagReport() diverts any kind in
AUDIT_KINDS to auditLog() before it can reach the inbox, so a one-line
this.flag(...) cannot forget to log. Covered: admin_grant, admin_wipe,
admin_rollback, admin_bulk_dismantle, admin_lock,
admin_unlock, ops_restore, trade, global_wipe,
save_retry, chain_resync, grants_revoked,
admin_traffic_block, admin_traffic_clear. (The bare traffic_block kind is the
automatic threshold's anomaly and stays a report, not an audit row.) Written straight to the log through
auditLog() rather than a flag: admin_rename, sock_migrated,
mon_migrated, birth_migrated, birth_recorded.WorldDO now sets
_isWorld); player-side actions ride the same carrier the reports do, ahead of them.GET /api/admin/acctlog?sid=&limit=, behind the same
steamid allowlist as every other admin door. There is deliberately no player-facing route to it anywhere
in the build, and the probe asserts that there is not.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.
GET /api/ops/market/exports?steamid=&day= (ops key, in KEY_GATED) reads the World’s market_world mirror — rows newest first and farm counts per (steamid, day). The per-account daily cap, the account-age gate and the kill-switch flag are vars (MARKET_EXPORTS_PER_DAY 5, MARKET_MIN_SESSION_DAYS 7, MARKET_ENABLED off). Runbook dfmc-client steam/MARKET.md; gate tools/probe_market.py 5/34 → 34/34.wdel (Tower rulings card T68, D: “WHY WOULD THE PLAYER EVER GO OVER CAP IF ALL OF THIS IS SERVER AUTHORITATIVE? REVIEW THE IMPLEMENTATION”; dfmc-client lane soulcap). On dev. The review of every server path that puts a soul or an item in a bag: the save door strips souls past max(cap, what the account held), the breed egg refuses on a full stored bag, the breedfix regrant defers past room, hatch is egg→creature one for one, devour and dismantle only remove, a merge moves Wraithgold and identities only, an admin grant mints an offer the save door bounds — and /api/backup/restore stored the payload as given. That was the hole: an ops restore of a 34/30 bag was then “what the account held” and the save door kept it for good. The door now counts souls and items (soulCountOfSave / itemCountOfSave: non-soul rows in bag+bagAdd) against the cap the server owns — the slot_caps row when one exists, else what capsOf will adopt from that payload at first contact — and answers 409 over_cap with both counts listed unless force=1, which stores as given and flags ops_restore_over_cap. The client is never the cap. Handoff 2026-10-30 Step 1.2 rides it: GET /api/backup/accounts carries wdel {n, due24h} (?now= as del_due takes it), so the age-purge nightly re-mark is verifiable from an ops read door. Gate tools/probe_t68_soulcap.py.items on its gone column, which only the items schema upgrade adds; an account whose items table had never been upgraded logged errs 1 / items -2 on its MAINT line. Maintenance now runs that upgrade before the slice. No retention window changed.docs/ccu-1000-2026-10.md). On dev. /api/_internal/autorept on the WorldDO rides most battles and saves, and it counted the three per-class budgets (suspect 400, sync 300, system 300 a day) with three COUNT(*) scans of the unindexed feedback table — the whole inbox — on every relay, audit-only and update-only relays included; once a cap is full every refused flag arrives new again, so the scan stayed on the hot path at peak. Now an audit row or an in-place update reads nothing for the budget; a new row reads the day’s auto rows once, buckets them by class, and the counts are kept on the World object for 60 s, raised with every insert. The caps, the per-class split and the reporting budget hit refusal row are unchanged; a row filed by another writer is counted at the next pass (≤ 60 s). No index was added (one on ts would write a row on every in-place update). Every WRCOST line now also names its object (do: W/P) and flags the first request after a wake (wake). Gate: tools/probe_a14_autorept.py 2/4 → 4/4 (relay reads 2,706 → 2 at an 899-row inbox).lock, save-history and rollback (adminPlayerOp, GM and Steam admin) and the ops /api/backup/history / /api/backup/rollback acted on the id they were given: a lock or rollback aimed at it wrote the loser’s object and left the winner untouched. Each now resolves the target through the World ledger’s account row first (mergedWinner; the loser’s own merged-to hops before the backfill is done), acts on the winner and never writes the merged-away id. The answer carries both ids (steamid/sid = the winner, asked, merged_into; an unmerged id answers as before) and the admin_lock / ops_rollback audit row, on the winner, names the asked id. Gate: tools/probe_merge_follow_ops.py 2/7 → 7/7 on a local worker.rel-0.80”, the backlog, a session memory). It is now docs/release-ritual.md in dfmc-client, and the others point at it: dev fast-forwards main, no rel-* branch; a non-empty git log origin/main..dev -- play/ makes any push a release; the build is live + 0.01 exactly; GATES_STRICT is on for any non-empty value; wrangler only through tools/cf_wrangler.sh; Worker secrets go on after the deploy (the worker answers 503 until they do); CSVs come from the worker’s codex door since A4, art is still jsDelivr at the pin, so the “do not advance DATA_PIN” warning stands for art. tools/discord_post.py posts release_gate’s notice to the channel webhook kept in local-only/secrets/discord.env (never printed, mentions off, split under Discord’s 2000 characters, --dry-run; silent hotfixes post nothing); the post stays D’s to send. The missing OPS entries for 0.84, 0.85 and 0.86 were rebuilt from the repo history and Workers Builds check-runs.
apply=1 on breedfix, sockmig, monmig, keymig, birthmig, reparts, rollback and wallet_credit, breedfix revert=1, the PvP force-end pvpq?end= - wrote on any method. A write sent as anything but POST now answers 405 method_not_allowed; the dry runs stay GETs. The key goes in the x-ops-key header (SEC-02); the URL form is refused once OPS_KEY_QUERY is 0, a 0.90 release-checklist step. The door table above says which method each one takes. Details: docs/security-review-2026-10.md in dfmc-client.POST /api/feedback/export ran UPDATE feedback SET status=? WHERE id=? and pull_feedback.py --set kind:id=status threw the kind away, so a --set naming the wrong kind for an id re-statused a different report with a 200. The door now takes items:[{kind,id}] (or kind + ids), answers 400 kind_required for an id without a kind, checks every pair before any write and answers 404 with the missing pairs while writing nothing, then updates WHERE kind=? AND id=?. closed joins the accepted statuses and an optional note is appended to the report body; the tool gains --note and --close-before KIND ISO (D3: the pre-wipe auto rows close as “pre-wipe (T37)”). The GM panel’s /api/admin/feedback/mark is unchanged. Gate: tools/probe_a20_kindid.py.wrangler.jsonc (T19 part 4; Tower rulings card T19, D 2026-10-18: “Close all, after the wipe”). LIVE on release since 2026-10-23 (0.89); the release worker gets its secrets in the release ritual. SESSION_SECRET, SPAWN_SECRET, GM_SECRET and FEEDBACK_KEY sat as plain vars in wrangler.jsonc and wrangler.keyprobe.jsonc — in the repository, in every clone, in the dry-run log until de97f81 redacted it. They are Cloudflare Worker Secrets now (wrangler secret put, new values, 32 random bytes base64url). The worker reads each from env.<NAME> exactly as before (a var and a secret are the same binding) and refuses every request with a 503 and one ops line naming what is missing (names, never values) when any of the four is absent, at the top of fetch() before maintenance and before any Durable Object — a deploy that lost one used to run open (an empty SESSION_SECRET signs every token with an empty key; SPAWN_SECRET fell to 'dev'). A var and a secret cannot share a name (API 10053), so the order is deploy-without-vars first, then the puts: the dev worker answers 503 between the merge’s deploy and python tools/put_secrets.py --name dfmc-client-dev; release the same in the ritual (wipe runbook § 5, docs/secrets-runbook-2026-10.md). Rotating SESSION_SECRET signs every player out once; GM_SECRET ends every GM session. Locally, wrangler dev reads the ignored .dev.vars (wrangler’s own mechanism; it wins over any vars in a generated probe config), and every probe and ops tool asks tools/_devvars.py / _devvars.mjs instead of regexing a config (137 files rewritten). The live keys for the ops tools (gm_login, pull_feedback, pull_advlog) come from the environment or a local-only record per worker; dev and release keys differ now. Gate: tools/check_secret_vars.py in gates.sh refuses any of the four under vars, a tracked .dev.vars, or a retired value in any tracked source. Probe tools/probe_t19e.py 3/7 → 7/7.WIPE_EPOCH 2. Scrap/Ink/Dust/Essence had server-booked earns (the dismantle doors) and spends (roll, slot, shop) in wallet_ledger since BOOK, but the class door and the Shadow Trader judged the stored save’s wallet, a single roll attempt judged nothing (ruling #3), and the save door only rate-watched the claim - the client’s number was the money. Now: (1) every door books the ledger at every epoch - the class door (source class, ref <realm>:class:<line>), the trade door (one trade spend row per paid line, one earn row for the target), the ops credit door (/api/backup/wallet_credit takes cur Scrap/Ink/Dust/Essence beside food); (2) post-wipe (the account has claimed epoch ≥ 2 - the same line as the grade freeze) the class, trade and roll doors check SUM(delta) and refuse insufficient (402; the all-in bound is the ledger), write the ledger’s balance into the stored wallet and answer it (wallet on the roll answer, adopted by the client); (3) the save door clamps a claim above ledger + grace (ledger_grace.food) to the ledger - silently when the spends booked since the previous accepted save explain it (a save built before a door’s spend landed), flagged wallet_over_ledger otherwise - and hands the clamp back in srv.wallet; a claim is never raised. Pre-wipe nothing refuses or clamps (PROVENANCE ENFORCEMENT IS WIPE-GATED, D 2026-08-18). Ichor keeps its ledger.food_total ceiling, Wraithgold its entitlement ledger. No source of the four is client-minted: the dismantle doors, the save door’s own dismantle verification, the trader and the ops credit are the only mints; battles pay Ichor only. Gate: dfmc-client tools/probe_t19a.py 8/22 -> 22/22 on a local worker at both epochs.breeding.csv Egg_Dismantle_Ichor ×0.4 for Epic and above (115/180/270/410/610/860 → 46/72/108/164/244/344); dismantle.csv Gear and Tag Scrap ×0.75, tier 10 ×0.5 (200 rows; Gem, Rune, Scroll, Key, Material untouched). After: Ichor 1,398 / 2,004 / 2,888 / 3,007 per hour (early unchanged, endgame −35%), Scrap at 119 3,287/h (halved); one T10 piece Epic→Void 6.8 h → 13.5 h. Nothing in src/dismantle.js or the doors changed — the data is the fix; it reaches the live Worker and client when DATA_PIN advances and the bake runs. The trader half is on system-shadow-trader. Gate: dfmc-client tools/probe_econ_bands.mjs 10/12 → 12/12 (in gates.sh); the table is tools/sim_econ.mjs./api/backup/keymig (F131, dfmc-client review lane KM, 3034ef9). LIVE on release since 2026-10-23 (0.89); run at the 0.88 deploy. Since F081 / F6-041 a credited boss battle spends one server key row and refuses without one (boss_nokey); saves older than server-minted keys hold Key items the server never recorded (3 of 21 live accounts on 2026-10-01). GET /api/backup/keymig?sid=<steamid>&realm=standard[&apply=1], ops key, dry by default like sockmig/monmig: it reads the STORED save (bag + bagAdd), resolves each key’s zone (keyId, else name, else keyFloor), counts per zone what the save holds against the rows the battle door would accept (its own predicate), and mints the difference, never more — reusing the save item’s uid first (no row: inserted; an unsourced row: flipped), then fresh uids the way the key lane mints. Origin grandfather; a spent row is never revived; one keys_grandfathered audit row per apply. A pure item-row mint: the save is not rewritten, so no ver bump, snapshot or session-epoch rotation. Idempotent. tools/keymig_all.py walks /api/backup/accounts, dry by default, --apply to write. Gate: tools/probe_keymig.py 16/16 (3/16 before); probe_bosskey.mjs 9/9, probe_lanem_doors.py 17/17.x-dfmc-env stamp rebuilt every /api request as new Request(req, {headers}), which took the incoming body stream; a door that answers without reading its body (POST /api/backup/sweep, /api/lb/backfill) left it live past the reply, the runtime read it afterwards (“Can’t read from request stream after response has been sent”) and the next request on that connection died with an empty 500 — probe_backup’s second forced sweep, about two runs in three. The stamp now reads the body to the end before any route answers and the rebuilt request carries the bytes. Gate: tools/probe_lane_z_bodydrain.py 4/4 (1/4 before); tools/probe_backup.py green 5 runs of 5.autoRelay() three tries for the audit rows it carries to acct_log; a first attempt that landed but lost its reply was re-sent and appended a second copy of the same legal action. Every audit row now carries a stable aid minted where auditLog() creates it (kept across every retry and every queue carry-over; a row from an object still on the old build gets one derived from the relay header and its position, before the first attempt). acct_log gained an aid column with a unique index and both inserts are INSERT OR IGNORE, so a repeat is a no-op while a second real action is still its own row. Rows written before this have no aid and are untouched. The lost reply is simulated by AUTOREPT_REPLAY_TEST=1, a test-harness var set only in the probe’s own config copy (never in production, like RATE_OFF). Gate: tools/probe_lanew_relay.py 8/8 (5/8 on the tree before); tools/probe_laner_ops.py 12/12.tools/balance_queries.py and tools/replay_keylane.mjs still read dfmc_battles across dev and release. Both now filter on blob7: release by default (every point not stamped dev, so release points written before the stamp reaches main still count), with --env / ENV dev or all. Dev points older than the stamp are unmarked and age out with the 90-day window.dfmc_activity; dfmc_battles and dfmc_wrcost still mixed dev and probe traffic into the release console. Both points are written inside the objects, whose isolate may never have served a Worker request, so the Worker now stamps every /api request it forwards with x-dfmc-env (overwriting any client value) and the object reads it (falling back to the isolate’s own knowledge for internal calls). Battles carry it as blob7, write cost as blob6; the two panels filter on it. Points written before this carry no environment and drop out of the filtered panels. Gate: tools/probe_lane_v.mjs 16/16 (4/16 on the tree before).auditLog rows (ops_restore, admin_wipe, the rollback) never reached the relay and acct_log stayed empty; the header is now kept (and still stripped before anything leaves the edge), and the relay retries three times, then writes one AUTOREPT_LOST log line carrying the audit rows in full. F256/F257: ops_rollback (the working /api/backup/rollback), ops_breedfix and ops_breedfix_revert are audit kinds (acct_log, never pruned) instead of bug reports + 30-day anomalies; the grandfather stamp writes a grandfathered row per account it flips. F259: the ops_restore row records the ratchet actually written (max(1, maxFloor)), not the old dev-seed 9. F238: the housekeeping sweep is hourly again, gated on a persisted last-run time: one point read per save, a write only when it runs (it was a counter written on every save, sweeping every 100 saves). F236: the 30-day prune of item tombstones moved from every changed save into that sweep's shared, sliced budget. F241: the two 2026-07-24 one-off wipes that every new object ran for nothing are removed. F264/F265/F220: the GM save-history panel says “no history” on an empty result; dead realm read in /api/admin/wipe removed; the rate-limit comments state the sizes in force. Probe tools/probe_laner_ops.py./api/usage reports the cap spend() enforces (an admin override in wmeta beats the constant; the constant rides along as default when they differ). F251: the activity panel’s rate is labelled for its real denominator: errors among the requests the counter writes (the classed route families plus every error), not all requests. F252: release and dev bind the same Analytics Engine dataset, so each activity point now carries its environment (blob5) and the panels read their own; dev and probe traffic no longer count as release players. Points written before this carry no blob5 and drop out of the one-hour / one-day windows. F254: the entitlement R2 mirror is counted in the PLAYER object’s own usage table, not the world budget /api/usage reads; the code comment no longer claims otherwise. F248: the ops bulk dismantle (/api/backup/dismantle) refuses a locked item, like the mutant and the held-in-socket refusals./api/backup/*, /api/usage, /api/ledger/reconcile, /api/lb/*, /api/gm/bootstrap, /api/admin/wipe/rename, /api/account/resolve and /api/feedback/export, was read only off the URL query (?key= / ?k=), which Workers Logs, Logpush, the request sampler, proxies and shell history record verbatim. Every door now reads the X-Ops-Key request header first (one helper, opsKeyIn; the object-side compares are the constant-time opsKeyOk). The query forms are still accepted for now, so an old tool keeps working; the live ops tools (pull_feedback, backup_saves, pull_advlog, gm_login, promote_lvl90, replay_keylane) already send the header. Local probes still use the query against wrangler dev with the probe-only key. Probe tools/probe_opskeyhdr.py 12/16 → 16/16./api/roll ran SELECT COUNT(*) FROM rolls WHERE ts > now-60s on every roll; rolls has no ts index, so it read the whole 7-day table each time - the last per-roll scan after F6-023. The count is now an in-memory list of the object’s draw times, seeded from the table once per wake (so a restarted isolate still sees the last minute) and appended on every recorded draw. Same window, same limit, no row written. probe_rollsweep rowsRead growth per roll: 1.0 → 0.0./api/roll ended every request with DELETE FROM rolls / roll_att WHERE ts < now-7d; neither table has a ts index, so each roll scanned both tables' whole 7-day window. The sweep now runs when the player object's in-memory stamp is over an hour old (a woken object sweeps on its first roll); same 7-day horizon, zero rows to keep the stamp. No ts index was added: an index is one more billed row written on every roll. Measured over 40 enchant rolls (tools/probe_rollsweep.py, WRCOST): rows read 1,722 → 864, rows written 87 → 87.CHANCE_CSVS file, floors.csv included), the item bases / legality index (items.csv) and the species set (creatures.csv) split the file on newlines before parsing, so a quoted cell holding a line break (classes.csv already authors them) would have shifted that row's cells. All three now use the worker's one quote-aware parser, which also now treats a bare quote mid-field as a literal (as move_icons.csv authors one). No change on today's pinned data. Probe tools/probe_csvreaders.mjs 46/49 → 49/49 against Python's csv.wrBreaker runs: layer 3 compares the closed hour to 10× the p90 of the retained active hours and needs at least 12 of them (since 2026-09-02, not the first request against the median); layer 2’s 70/90/100 wr_budget flags and the diagnostic anomalies shed at the ceiling are named.save_hist and the lock check live in that player’s PlayerDO, but /api/gm/admin/* and Steam-admin /api/admin/* sent lock, save-history and rollback to the single WorldDO: a GM lock wrote a row no player object reads (the player kept saving), Save history was always empty and Rollback answered 404. The Worker now routes the three to the target account’s PlayerDO (adminPlayerOp: _internal/save_hist, _internal/rollback, new _internal/lock), the same hop /api/backup/history and /api/backup/rollback make. Lock also keeps its WorldDO row. The GM-session and ADMIN_STEAMIDS gates are unchanged; a malformed steamid is refused 400. Gate: tools/probe_f225_adminplayer.py 5/14 → 14/14 on a local worker.dismCreditRow since #A4 (dev); GM login is password only and bootstrap mints no TOTP (#TOTP-removal, 2026-09-28); the rate classes carry today's buckets (ip_all 600/360, plus me, mm, pvpm, roll) and TRAFFIC_BLOCK_N is 50 as deployed; RATE_READ_ONLY holds save_read and me; adventure-log uploads are capped at 1.25 MiB, keyed per device, and a round closes itself after 3 days (advlog_until); the ops door list is complete, split into read and write doors; the GM activity panel (dfmc_activity, /api/gm/analytics) is documented; restore writes the save parts through writeSave; the audited kinds list matches AUDIT_KINDS (admin_traffic_block/admin_traffic_clear) plus the direct auditLog kinds; /api/admin/flags takes sid/kind/limit and answers {ok, rows, counts24h}.items.birth holds each creature's and egg's birth record (see system-networking); /api/backup/inv now shows it per row; /api/backup/birthmig?k=&sid=&realm= reports what a STORED copy would record, strip and keep, and &apply=1 does it through the save facade (ver bumps by one, so it is for accounts that are not playing — a playing account migrates itself on its next save). One audit line per save that moved records (birth_recorded) and per migration (birth_migrated).wr_rate_anomaly names the request, the object class and the account (three rows — 174/195/228 rows — had reached the operator as “rows this hour” with no object, and could not be attributed); breed_claim_bad's elapsed row says by how many seconds of the pen's authored wait the claim was early (the reason word in the answer stays elapsed). Why: twelve elapsed rows on one account in the day after the pen-anchor fix reached release, all on pens that were running before it — a verified claim advances started by one wait from the OLD anchor, so a pen anchored late stays late for as long as the pair runs. The delta is the number a re-anchor tolerance would need; measured first.+0 it has always been, and dismantle.csv lost its 1,000 dead +1…+10 gear rows (the lookup key never asked for them).XPCurve.csv cumulative for level 90 (3,963,264) — only that field, verified by re-read, one ops_restore audit row per write, idle accounts only. Done: accounts …5081 (85), …7635 (88), …0634 (87); creatures Ingot and Drakeling (60, 60), Nosferatu (83), Omnichimera and Crestmantis (85, 85), Ferrodrake (80), Colony (5), and D's own Typhon and Charybdis (87, 87) once his tab went idle. Still owed at the time of writing (mid-session at every retry): account …3878 (85 → 90). tools/promote_lvl90.py is the repeatable form: dry by default, --apply writes only accounts idle 10+ minutes, re-reads and reports; running it when nothing is owed changes nothing./gm, cached world snapshot, adventure-log extraction (built by Jeff, PREP_GM_CONSOLE.md §8–§9d; LIVE on release since 2026-08-23 (0.61)). New “GM console” section above carries the mechanics: ops-key-gated enrollment (inert until the first bootstrap), PBKDF2+TOTP login with per-account lockout and a permanent gm_audit trail, a 12-hour HMAC session with no v1 revocation, gm:<user> identities holding the full /api/admin/* surface, a snapshot data plane that accumulates on the existing nightly backup cycle and serves one cached value, and metadata-only adventure-log rounds into the private backup bucket (turnsLog stripped server-side, 256 KiB cap, budgeted R2 writes). Three new ops-key doors joined the table: gmsnap, advlog/list, advlog/get. Gates: tools/probe_gmconsole.py, tools/probe_gmadmin.py, tools/probe_gmsnapshot.py, tools/probe_advlog.py; live smoke on /dev/gm (page 200, unauthenticated doors 401, bootstrap without the key 403)./api/backup/breedfix (D order “fix both”). LIVE on release since 2026-08-22 (0.59). Companion to the breeding fix on Breeding: between the 08-21 server batch and 0.59 every breeding pair the ledger could verify was recorded as Worn and laid eggs off the Worn row. GET /api/backup/breedfix?k=&sid=&realm= is a dry run: it lists every stored egg draw whose recorded pair was Worn and which the ledger verified, with the real pair grade recomputed from the pen's recorded parents' ledger grades, the grade the draw's own keyed lane gives on that row, what would be done, and the Ichor owed (pair price minus the Worn price booked). &apply=1 does it: held unhatched eggs are re-graded in place (save and ledger row), hatched or dismantled ones are regranted as a fresh egg of the same line at the pair grade (ledger origin breed, save origin marked repair), the Ichor difference is booked once (source breedfix, keyed on the draw), every running pen's recorded grade is re-derived, the save is written one version up with a fresh timestamp, and the account is flagged ops_breedfix. Each repaired draw is marked in place, so a re-run changes nothing. Skipped and named, never guessed: an honest Worn pair, a pen whose pair changed since the draw, a parent without a legal grade, or a lane that no longer reproduces the stored Worn draw. A player online at apply time can overwrite the written save with their next flush — apply while the account is idle and re-read the live save afterwards. Gate: tools/probe_breedfix.mjs.events_seen (7d) and drops_granted (30d) prunes per event, ungated; ~7 days after a heavy day one battle inherited the whole day's expiry in one statement — 2,193 rows on the write breaker's first live morning, filed as auto report #458. Both prunes moved into maybeMaintain beside the three already there; all five now drain through a shared budget of billed rows per hourly run, announced by a MAINT console line. Replay protection can only run longer during a drain, never shorter. New section “The write breaker and the sliced retention” above. Gate tools/probe_battleprune.py 6/6, red 3/6 first; six adjacent suites re-run green./api/_internal/bulk_dismantle with scope 'all' now leaves any Monster with mut:true untouched — it was the one dismantle running server-side without the client’s gate. src/dismantle.js keeps paying a Mutant claim on purpose, so a stale release client loses nothing; probe_dismantle_parity.py gate B4 pins that divergence. Ops probe tools/probe_dismmutant_ops.py 4/4, red 2/4 against the build it replaces.acct_log in the WorldDO, read only through GET /api/admin/acctlog. Reports are one row per account per kind per UTC day updated in place with the day’s running count, tagged suspect/sync/system as a sort order rather than a verdict. Budgets are per class and a refused report is itself reported; the carrier no longer cuts its own payload mid-JSON, which used to discard whole batches silently. Nothing gates anything yet — enforcement waits on a week of live accounts. Probe tools/probe_flagrept.mjs AFTER 25/25, BEFORE 12/12, and the probe of that name had itself been crashing since 2026-08-12.status='open', which no reader lists and no mark endpoint accepts, and inserted into the raising player's own object while the inbox lives in the WorldDO — either alone was fatal and neither raised an error. Status is now 'new'; the row is queued in the raising object, carried out on the x-ht-autorept response header, and relayed to the WorldDO by the Worker under waitUntil, so no Durable Object gains an outbound fetch and hibernation is unaffected. The forty-a-day ceiling moved to the world door where the table it counts lives; the per-account-per-kind-per-day dedup stayed where the account's history is. New section “Why no automatic report ever arrived” above records both causes, the live evidence for each, and the relay. This is why report #122 had to be noticed by D rather than caught by looking — every save-path detection since 2026-08-04 was written somewhere no door can read. Probe tools/probe_autorept.mjs 18/18, and 7/18 against the build it replaces.dfmc-client-dev through the settings API and smoke-tested, then to release; the settings API refused the release worker with error 10214 (latest uploaded version was not the deployed one), which is why it belongs in wrangler.jsonc rather than the dashboard in the first place. New “Denial-of-wallet” section above records the measurement method and the standing re-measure trigger.ctx was read when the player pressed send, which with autoplay running is several battles after the moment being reported — the cause of report #29 arriving stamped screen: resolving. openFeedback() now snapshots the context, and the payload carries openedAt, typedMs and an atSend map holding only the fields that drifted while the report was written.b.shot is ignored rather than rejected so release clients do not lose reports during the rollout; purgeShots() blanks the stored images once behind a wmeta flag, on the ops door. The new “What a bug report actually carries” section above is the first time the payload has been written down.wallet rate class (LIVE on release since 2026-08-08 (0.42)). Currency spends no longer share the generic other bucket, where normal play left too few tokens for a couple of clicks on the inventory + and produced a 429 that was both shown as PURCHASE FAILED and filed as a traffic breach.saves while the history ring still topped out at ver 64 and no save_parts row existed. Harmless while saves is authoritative; a silent data-loss bug the moment reads cut over to parts. Also: the first sweep of these doors was run unpaced and dropped responses were swallowed into empty results by the tooling, which read as missing data on accounts that had answered correctly minutes earlier — pace the requests and print the status./api/_internal/pvpq had been written among the PvP routes, roughly 1,150 lines below the blanket session gate in MainDO.fetch, while all thirty-odd sibling internal routes sit above it. The Worker forwards internal calls with no session cookie, so the object refused the request before the handler ran and the ops key was never the problem. Moved above the gate with the invariant recorded at the call site. No logic changed — the diff is the move plus a comment. Also fixed on this page: the “Ops reads” section had been appended after </body></html>, so the page had been shipping malformed since it was added.src/dismantle.js now carries it on the src/power.js pattern, proven 9/9 against the live client over all 1,515 authored rows. Server-side validation by save-diff is the next batch.save class for seven hours and the symptom was indistinguishable from a broken login; two more accounts had been silently blocked on floor and db for three days. GET /api/save moved to its own save_read class which is never blocked and never escalates, and blocked=1 now ages out after SB_EXPIRE_MS.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.
| Door | Answers |
|---|---|
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. |
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.
/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.)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:
| Field | Share | Average | What it is |
|---|---|---|---|
shot | 79.2% | 50,066 B | REMOVED 2026-08-05. Base64 JPEG, inline. |
logs | 19.9% | 12,576 B | Browser 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. |
ctx | 0.4% | 275 B | build, floor, phase, mode, playerLv, ichor, invCount, invCap, screen, viewport, dataWarns, saveTs, saveAt, plus openedAt, typedMs and atSend (see below). Capped at 4,000 chars. |
body | 0.2% | 120 B | What the player typed. Capped at 4,000. |
ua | 0.2% | 110 B | User agent, server-stamped, capped at 300. |
title | <0.1% | 18 B | Capped 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.
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.
openFeedback() snapshots feedbackCtx() and a timestamp. That snapshot is what ships as ctx.openedAt — ISO timestamp of the moment the reporter was opened.typedMs — how long the report took to write. A large value is the warning that the run moved underneath it.atSend — present only when something drifted, and carrying only the fields that did: floor, phase, screen. Drift is recorded rather than silently overwriting the anchor.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.
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.
ctx.screen says resolving.html2canvas came from cdnjs at the moment of the click; blocked or slow meant no image, silently.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.
/gmLIVE 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.
POST /api/gm/bootstrap, ops-key-gated in its own handler (maintenance or not). Creates or re-enrolls a
named operator account and sets its password; every enrollment is audited. Since 2026-09-28 (#TOTP-removal, Jeff's
order) bootstrap mints no TOTP secret and answers {ok, user}; a re-enrolled account keeps whatever
totp_secret row it already had, unused. Until the first bootstrap the
account table is empty and login is impossible — the console ships inert./api/gm/* skips the per-steamid rate limiter);
every attempt — unknown user, locked, bad password, success — lands in the permanent
gm_audit table with timestamp, actor, action and IP.ht_gm_session cookie, HMAC-signed with GM_SECRET,
12 hours, HttpOnly/Secure/SameSite=Lax. v1 has no server-side revocation list — expiry is the only
thing that takes a token away, which is why it is short./api/gm/admin/<action> verifies the GM session, then forwards
internally to /api/admin/<action> under the identity gm:<user>; the admin
allowlist accepts gm:* alongside ADMIN_STEAMIDS. A GM session therefore holds the
full admin surface, and every admin door’s own permanent per-account recording applies to it.GET /api/gm/snapshot (GM session) and GET /api/backup/gmsnap
(ops key) both serve the cache, so reading it fifty times a day costs the same as once. Until the first full
cycle completes it reports that no snapshot exists yet.POST /api/admin/advlog/start, audited). The round closes itself: start stamps
advlog_until = now + 3 days and answers {round, until}; after that the active round reads
as none and uploads stop (D2, D ruling 2026-09-04). Clients poll the active round id at most hourly and upload
once per round per device: chapters and battle metadata only — the round-by-round combat text
(turnsLog) is stripped server-side regardless of what the client sent, uploads are capped at 2,000
battles and 1.25 MiB (1,310,720 chars; raised by D2 to carry advArc, the gzip+base64 archive blocks
holding older history), and each R2 write passes the shared spend budget first. Files land in the private
backup bucket under advlog/<round>/<steamid>/<device>.json, one per device so no device
overwrites another's; /api/backup/advlog/list also returns devices (sid → device count); a receipt row per account per round
records time and size, and the status door reports the count and the last five check-ins.ACTIVITY_AE, dataset dfmc_activity) per /api/* request that
falls in a console class (hatch, roll, battle, save writes,
login) or answers an error status; other requests write nothing. The point carries the class, a hashed
session cookie (first 8 bytes of SHA-256, never the steamid or raw cookie), the status and the path; the write is
fire-and-forget and a missing binding just stops emission. GET /api/gm/analytics (GM session) reads it,
plus dfmc_battles and dfmc_wrcost, through the AE SQL API using the Worker secrets
CF_ACCOUNT_ID / CF_AE_TOKEN: active players over 5 m/1 h/24 h, requests per class
and error rate over the last hour, battles and write cost per system. Every point in all three datasets carries its
environment (dev / rel: dfmc_activity blob5, dfmc_battles blob7,
dfmc_wrcost blob6) and every panel counts only the environment it is served from, because release and dev
bind the same datasets. Cached 60 s per isolate; with the secrets
unset it answers {ok, note:'Analytics token not set', panels:null}.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.
| Door | Answers |
|---|---|
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=1 | The
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.
| Door | Writes |
|---|---|
POST /api/backup/restore[&force=1], /api/backup/recover | Install 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, /revoke | Mint an admin grant; flip the account's unsourced item rows to origin grandfather; revoke entitlement grants. |
POST /api/backup/dismantle | Bulk dismantle, priced and credited since #A4 (see Dismantle yield above). |
GET /api/backup/rollback?sid=&ver= (preview) / POST ...&apply=1 | Roll 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=1 | Legacy 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/sweep | Runs the backup sweep now (the cron's code path). |
POST /api/backup/reverse | Refund 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=1 | The
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. |
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.
/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.