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:
47
backend/app/models/award_claim.py
Normal file
47
backend/app/models/award_claim.py
Normal file
@@ -0,0 +1,47 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db import Base
|
||||
|
||||
|
||||
class AwardClaim(Base):
|
||||
"""One row per milestone a seeker has ACTUALLY been paid for a presence.
|
||||
|
||||
The three reward faucets in `app/ws.py` (the ritual milestone, a verdict,
|
||||
a Passage beat) each used to be guarded by a set held on the in-memory
|
||||
`SeanceState`. That guard is per-CONNECTION: a client that disconnects and
|
||||
reconnects — or simply opens a second websocket — got a fresh, empty guard
|
||||
and could be paid all over again for the same spirit. `summon_limiter`
|
||||
bounded the rate of that, never the total.
|
||||
|
||||
This table is the durable form of those guards. The UNIQUE constraint on
|
||||
(user_id, entity_id, award_key) is the actual enforcement, not a
|
||||
belt-and-braces afterthought: `app.ws._claim_award` claims a milestone by
|
||||
INSERTing here and treating an IntegrityError as "somebody already paid
|
||||
this", which is what makes two connections racing the same award resolve
|
||||
to exactly one payout instead of two.
|
||||
|
||||
`award_key` is the milestone within a presence, not a currency:
|
||||
`ritual`, `judgment:<verdict>`, `passage:<layer>`, and `crossing` (claimed
|
||||
by BOTH roads to a crossing — the Passage's `release` beat and the
|
||||
`cross_over` verdict — so one spirit crosses once whichever road got there
|
||||
first).
|
||||
"""
|
||||
|
||||
__tablename__ = "award_claims"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id", "entity_id", "award_key", name="uq_award_claims_user_entity_key"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entities.id"), index=True)
|
||||
award_key: Mapped[str] = mapped_column(String(64))
|
||||
claimed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
Reference in New Issue
Block a user