fix: reward guards now survive a reconnect
The three replay guards shipped earlier lived on the in-memory SeanceState, which made them per-CONNECTION. I flagged that as an open residual at the time: drop the socket and reconnect — or just open a second one — and the client got a fresh empty guard and could be paid again for the same spirit. summon_limiter bounded the rate of that, never the total. `award_claims` is the durable form: one row per (seeker, presence, milestone), with a UNIQUE constraint doing the actual enforcement. The claim is a bare INSERT and losing the race raises IntegrityError, which is caught and read as "already paid" — a check-then-insert would let two sockets both read "unclaimed" and both pay. `crossing` is claimed by BOTH roads, so a spirit crosses once whichever road arrives first. Measured with the durable claim disabled: 5 reconnects paid 75 extra essence on the ritual, 60 on a verdict, 140 on the passage, and two simultaneous sockets paid 30 for one 15-essence ritual. The four tests that were failing were the tests, not the guard. They compared raw balances across reconnects, but re-opening a channel IS a summon, and SUMMON_ESSENCE_TRICKLE is paid per summon by design (inventory.py:42, bounded by summon_limiter rather than by any once-per-presence rule). The expected trickle is now stated explicitly so the assertion speaks about the milestone it is actually testing. Favor has no trickle, so it must not move at all — asserted separately. Anti-overshoot covered in both directions: a genuinely fresh presence still pays in full across a reconnect, a corrected verdict still pays on a second connection, and `test` stays freely repeatable since it never touches the ledger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,45 @@ async def lifespan(app: FastAPI):
|
||||
"END IF; "
|
||||
"END $$;"
|
||||
))
|
||||
|
||||
# The durable reward ledger (app/models/award_claim.py). `create_all`
|
||||
# above already builds this table on a fresh database; these two
|
||||
# statements are for an install that predates it — the CREATE is a
|
||||
# no-op there, and the constraint is what actually enforces
|
||||
# once-per-(seeker, presence, milestone), so it is asserted explicitly
|
||||
# rather than left to whatever the table happened to be created with.
|
||||
await conn.execute(text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS award_claims (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
entity_id UUID NOT NULL REFERENCES entities(id),
|
||||
award_key VARCHAR(64) NOT NULL,
|
||||
claimed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_award_claims_user_id ON award_claims (user_id)"
|
||||
))
|
||||
await conn.execute(text(
|
||||
"CREATE INDEX IF NOT EXISTS ix_award_claims_entity_id ON award_claims (entity_id)"
|
||||
))
|
||||
# Same catalog-check shape as uq_unlocks_user_key above: `ADD
|
||||
# CONSTRAINT` has no IF NOT EXISTS form. Unlike that one this
|
||||
# constraint is not defense-in-depth — it IS the guard: _claim_award
|
||||
# relies on the IntegrityError it raises to resolve two concurrent
|
||||
# connections claiming the same award down to a single payout.
|
||||
await conn.execute(text(
|
||||
"DO $$ BEGIN "
|
||||
"IF NOT EXISTS ("
|
||||
" SELECT 1 FROM pg_constraint WHERE conname = 'uq_award_claims_user_entity_key'"
|
||||
") THEN "
|
||||
" ALTER TABLE award_claims ADD CONSTRAINT uq_award_claims_user_entity_key "
|
||||
" UNIQUE (user_id, entity_id, award_key); "
|
||||
"END IF; "
|
||||
"END $$;"
|
||||
))
|
||||
cleanup_task = asyncio.create_task(_session_cleanup_loop())
|
||||
try:
|
||||
yield
|
||||
|
||||
Reference in New Issue
Block a user