Files
qtalker---/backend/app/models/award_claim.py
Indiana c22b2f9c08 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>
2026-08-01 09:37:32 +00:00

48 lines
2.1 KiB
Python

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)
)