Unbounded essence/item farming: every other reward trigger (summon,
question, fragment) had both a per-user and per-IP limiter, but
ritual_start and judgment had none at all — and judgment has no
"already resolved" state either. A scripted client could replay
{"type":"judgment","verdict":"cross_over"} in a tight loop and mint
CROSS_OVER_ESSENCE (25) plus a 20% item roll every iteration, forever.
Same for ritual_start -> 4x ritual_step. Added ritual/judgment limiters
in both flavors, matching the existing pattern.
WS session crash: _handle_question did `if state.entity is None:
await _handle_summon(state)` then `assert state.entity is not None`.
_handle_summon returns early *without* setting state.entity when the
seeker is rate-limited, so the assert fired unhandled — and the message
loop only catches WebSocketDisconnect, so it killed the whole connection.
Reachable with no malice: click summon a few times impatiently, then ask a
question. Now returns cleanly (the rate_limited frame was already sent).
Essence double-spend: purchase_unlock() deliberately uses SELECT ... FOR
UPDATE to serialize concurrent purchases, but the three credit_essence
call sites in ws.py did an unlocked db.get() read-modify-write. An
unlocked read doesn't block on a row lock, so a reward computed from a
pre-purchase balance could be written after the purchase committed,
silently reverting the deduction — user keeps the unlock and the essence.
All three now lock the row the same way.
Entity mint collision: _summon does a racy check-then-insert against
Entity.signature and Entity.name, both DB-unique, with no IntegrityError
handling — a concurrent mint of the same signature crashed the session.
Forceable by a user with two accounts (anomaly frequency/magnitude are
client-controlled), and plausible without malice in wire mode, where
sample_network() reads host-wide /proc/net/dev counters so two idle
sessions genuinely measure the same traffic. Now retries once, which
re-runs the match against whatever the winner committed.
Also added a unique constraint on unlocks(user_id, unlock_key) as
defense-in-depth, with an idempotent catalog-guarded migration.
221 backend tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Implements the backend REST surface and WS wiring for
docs/superpowers/specs/2026-07-23-character-depth-ghost-log-design.md's
Workstream C:
- New models: UnlockRecord (unlocks), InventoryItem (inventory_items),
Sigil (sigils) — brand-new tables, picked up by main.py's existing
create_all.
- New app/inventory.py: unlock price table, item drop table/odds,
essence economy constants, sigil design validation, and an atomic
(row-locked) purchase_unlock() that guards against double-spend races.
- New app/routes/inventory.py: GET unlocks/items/sigils, POST sigils
(validates the placeholder {points, rune} shape, points capped at 12),
POST unlocks/{unlock_key} (402 on insufficient essence, 404 on unknown
key, idempotent re-buy).
- GET /auth/me now includes unlocks: list[str] and essence: int.
- ws.py: wires essence trickle + item_drop rolls into the one trigger
point that exists in this worktree today (_handle_summon, covering
every successful summon plus high-rarity summons); the other two
contract trigger points (correct judgment, successful ritual) belong
to Workstream B's not-yet-landed ritual/judgment WS handlers, which
should call app.inventory's same helpers once they land.
- User.essence: int added (Workstream B owns this column per the spec;
added here per orchestrator instruction so this workstream is
independently testable — merge controller reconciles the duplicate
edit).
Also fast-forwarded this worktree's branch onto master (it had fallen
behind several commits) so the files this workstream depends on
(shop.py, ws.py, entities.py, etc.) were actually present to build
against.
Tests: 109 passed (drop-roll statistical sanity with seeded RNG,
inventory/sigil CRUD, purchase success/insufficient-funds/idempotency/
unknown-key paths, /auth/me shape, ws summon-trickle and item-drop
wiring).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>