Conversation
zaxovaiko
added this pull request to stack #179
September 18, 2026 08:41
zaxovaiko
force-pushed
the
feat/promo-grant-lifecycle
branch
from
September 18, 2026 08:48
43e6392 to
d96ddb0
Compare
Everything that ends a grant, and the routes a player reads one through. They land together because the cron that drives the sweep is scheduled from a router factory - the only place in a plugin with container access after boot - so an expiry sweep without a router would have meant mounting an empty one to hold it. Expiry and forfeiture are one code path with two reasons. Both leave the same shape: a terminal status, a zeroed bonus balance, a ledger entry for the funds that died with the grant, and an audit row. The claim is the `status = 'active'` predicate on the update, so a second sweep, or an admin racing the sweep, writes nothing rather than a second entry. Each grant is closed in its own transaction: a sweep that took one transaction for the batch would hold it open across five hundred rows. Self-exclusion and account closure forfeit every live grant the player holds, immediately rather than at the next sweep - a bonus is money a player may not keep once they have excluded themselves, and "by the next cron tick" is not what that rule says. The two player routes return a grant as its holder sees it: the amounts, the progress, the expiry. The terms snapshot stays internal - it carries the operator's weighting, which is not the player's business. A grant belonging to someone else is missing rather than forbidden, because a 403 confirms the id exists. The promo value sets stop being declared twice. They moved to the isomorphic contracts zone when the events file needed them, and the module kept its own copies until now.
A security pass over the lifecycle found a leak a player could trigger on purpose, and it only became reachable because this branch made `expired` and `forfeited` reachable at all. Settlement asked the grant to take the money and treated a refusal as "this round drew no bonus", which credits the whole amount to the real balance. That was right while `completed` was the only closed state - those funds had already converted - and wrong the moment a grant could be expired or forfeited, where the funds were destroyed. A player could stake a bonus on a slow-settling round, self-exclude, and have the win paid out as withdrawable cash with none of the wagering done. Settlement now asks why the grant refused. Converted means the money is already real and the win belongs there. Destroyed means the bonus share of the round goes the same way the balance did, recorded as an entry against the closed grant so the ledger still explains itself. That also unsticks the void path: it used to throw on a closed grant, and a provider retrying a rollback forever had no way out. Forfeiture moved off the event handler onto a durable job. It moves money, and a handler that dies between two of a player's grants left the rest active with nothing to retry it - the sweep only looks at expiry dates, never at exclusion. The `status = 'active'` claim already made the work idempotent, so a retried job is free. The actor is carried through rather than discarded. Both events name who acted, and an admin closing an account or a player excluding themselves were both being recorded as the system. The balance a forfeit reports is now read under the row lock it is about to take, so it is the column the CHECK constraint protects rather than a sum derived from the ledger.
zaxovaiko
force-pushed
the
feat/promo-grant-lifecycle
branch
from
September 18, 2026 08:57
d96ddb0 to
c0a0e7c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #191. Everything that ends a grant - expiry, forfeiture on self-exclusion or account closure - plus the two routes a player reads their bonuses through. BF-592, BF-595 (the read half).
Why
Expiry and forfeiture are one code path with two reasons. Both leave the same shape behind: a terminal status, a zeroed bonus balance, a ledger entry for the funds that died with the grant, and an audit row. Writing them separately would have meant two chances to forget one of those four.
The claim is the
status = 'active'predicate on the update, not a read. A second sweep running beside the first, or an admin forfeiting a grant the sweep is about to expire, updates zero rows and writes nothing - rather than a second ledger entry against a balance that is already gone. Each grant closes in its own transaction: one transaction for a batch of five hundred would hold locks across the whole sweep for no benefit.Self-exclusion and account closure forfeit immediately, not at the next cron tick. A bonus is money a player may not keep once they have excluded themselves, and a five-minute window where they still can is not what the rule says. Both subscribe to events that already exist (
rg.self_exclusion.activated,player.account.closed).The forfeited amount is read off the grant's own ledger, not from the column the update just zeroed. The entries sum to the balance by construction, so what the grant still held is what the entries say it held - and if that identity is ever broken, this is one of the places it shows up rather than being papered over.
The player routes return a grant as its holder sees it. The
termssnapshot stays internal: it carries the operator's weighting, which is not the player's business. A grant belonging to someone else is 404, not 403 - a 403 confirms the id exists.Why these land together
The cron that drives the sweep is scheduled from a router factory, which is the only place in a plugin with container access after boot. An expiry sweep without a router would have meant mounting an empty router to hold the schedule.
What the review pass changed
A win on a grant this branch could now expire or forfeit was paid out as withdrawable cash. Settlement treated "the grant refused this" as "this round drew no bonus", which credits the full amount to the real balance. That was correct while
completedwas the only closed state - those funds had already converted - and wrong the moment a grant could be expired or forfeited, where the funds were destroyed. A player could stake a bonus on a slow-settling round, self-exclude, and collect the win as cash with none of the wagering done. Settlement now asks why the grant refused and destroys the bonus share when the grant's own balance was destroyed. The same change unsticks the void path, which used to throw on a closed grant and leave a provider retrying a rollback forever.Forfeiture moved onto a durable job. It moves money, and a best-effort event handler that died between two of a player's grants left the rest active with nothing to retry them - the sweep only looks at expiry dates, never at exclusion state. The
status = 'active'claim already made the work idempotent, so the retry costs nothing.The actor is carried through. Both subscribed events name who acted; an admin closing an account and a player excluding themselves were both being recorded as
system.The forfeited amount is read under the row lock the update is about to take, so it is the column the CHECK constraint protects rather than a sum derived from the ledger.
Also from the quality pass: event payloads are validated with
domainEventSchemas[...].safeParseinstead of a cast,GET /promo/grantspaginates like every other list route in this repo, and a dead conditional type and its suppressive cast are gone.Alternatives considered
A single transaction per sweep batch. Simpler to write, holds row locks across up to five hundred grants, and one failure rolls back the whole batch. Per-grant transactions mean a bad row costs one grant.
Reversing a completed grant's conversion on forfeit. Out of scope here: a grant that completed converted its funds to real money, and clawing that back is a different decision with a different audit story.
Deferring exclusion forfeiture to the sweep. Would have been less code and a window in which an excluded player still holds spendable bonus funds.
Risks
A grant that completed or expired before a forfeit arrives is left alone - the forfeit finds nothing active and writes nothing. That is the intended reading of "forfeit what they still hold", but it means a forfeit issued seconds after a conversion takes nothing.
The sweep takes five hundred grants per tick at a five-minute cadence. A backlog larger than six thousand an hour would grow; the cadence is a constant and the batch is ordered by expiry, so the oldest always move first.
This PR also drops the promo value sets that the module was still declaring alongside the copies in the isomorphic contracts zone. Same values, one declaration.