ADR-0003: Reward costs paid from a points ledger
Metadata
- Status: Accepted
- Date: 2026-07-23
- Deciders: EagraΓ Clainne Team (decisions taken in a design interview, 2026-07-23)
- Context: Implemented 2026-07-25 (proto, domain rules, transactional claim, delete refund, client and CLI) β see "Implementation notes" below for the small deviations made during the build
- Related: ADR-0001 (JSONB query convention β balance queries build on it); ADR-0002 (centralised RPC authorization); the frontend plan's "Morning check" flow ("10 more for Cinema Trip"), which currently has no mechanics behind it
Context
Rewards today are purely earned: a parent grants a reward to a person, and a
person's "points" are nothing more than the sum of the REWARD_TYPE_POINTS
rewards granted to them. domain.ClaimReward stamps claimed, the claim date,
and the owner β nothing reads or deducts points. Claiming a privilege or gift
spends nothing, so the frontend's promised flow β do jobs, watch points rise,
save up for the Cinema Trip β is fiction: the trip is claimable on day one.
Two existing wrinkles matter to this design:
- Claim re-assigns ownership.
ClaimRewardsetsreward.Userto whoever claims, so any member can take any reward. Harmless while rewards are free; unacceptable once claims move money. - The mutation seam is single-record.
svc.Mutategives each write a locked, audited read-modify-write over one row. A spend touches two records (the claimed reward and the ledger entry) plus a balance check across many.
The question this ADR answers: how do rewards acquire a points cost, and how is the spend recorded, enforced, and undone?
Decision
Cost on the reward, granted per person
Reward gains an optional int32 cost (points; 0 = free, so every existing
reward is unchanged). A costed reward remains granted to one person β the
model stays "the fridge door", not a shop: a parent grants "Cinema Trip, costs
40" to Γine, and she claims it when she can afford it. Any reward type may
carry a cost except REWARD_TYPE_POINTS β points are earnings, not
purchases, and a domain rule rejects a costed points reward at intake.
Spends are ledger entries with their own type
Claiming a costed reward writes a REWARD_TYPE_SPEND entry (new enum
value) against the claimer's ledger:
Γine's rewards:
POINTS +25 Chores done
POINTS +15 Helped with shopping
SPEND 40 Cinema Trip β written by Claim
balance = Ξ£ POINTS β Ξ£ SPEND = 0
value stays positive; the type carries the sign. A dedicated type keeps
every existing list working by explicit filtering (ready-to-claim skips SPEND,
balance sums POINTS minus SPEND) rather than by teaching every reader about
negative point rewards.
The spend entry carries a machine link to what it paid for: a new
spent_on field holding the claimed reward's uid. The name stays
human-readable ("Cinema Trip"); the link is what tooling and refunds use.
Only Claim writes spends. RewardService.Create rejects
REWARD_TYPE_SPEND as an invalid argument β the ledger writes itself, so it
always reconciles against claimed rewards. Corrections happen the honest way:
grant more points, or delete the claimed reward (below).
Claim is transactional and enforces the balance
Claiming a costed reward runs in one database transaction:
BEGIN
balance := Ξ£ POINTS β Ξ£ SPEND -- owner's reward rows, FOR UPDATE
if balance < cost β ROLLBACK -- domain verdict β FailedPrecondition
stamp the reward claimed
insert the SPEND entry (spent_on β reward uid)
COMMIT
- Insufficient points block at the server (
ErrNotEnoughPoints, mapped toFailedPreconditionlike the live-reference delete restriction). The UI shows the honest gap β "10 more points to go" β but the rule holds against anyone with curl. - The re-check inside the transaction closes the race where two claims pass the same balance; the ledger can never half-commit. This extends the mutation seam with its first deliberate two-record transactional path β the ledger is the money, and it is the thing worth deepening the seam for.
Owner claims, owner pays
Only the person a reward was granted to may claim it; an admin may claim on
their behalf, and in both cases the owner's balance pays. As part of this
change ClaimReward stops re-assigning ownership β the existing
take-anyone's-reward behaviour is retired.
Deleting a claimed reward refunds
Deleting a claimed, costed reward also deletes its linked SPEND entry in the same transaction β the points flow back. This is the family's undo for a mis-claim (there is no unclaim RPC, and a permanent mis-claim is too harsh for a household tool). The mutation audit trail on both records still tells the story.
Implementation notes (2026-07-25)
The build follows the decision above, with these deviations and refinements, recorded honestly:
- Unassigned free rewards stay claimable, and claiming one assigns the claimer. "Owner claims, owner pays" governs costed rewards; retiring the unassigned-free-claim path too would have broken today's only way for a member to pick up an open reward. This is the single remaining case where Claim writes ownership. Every assigned reward β free or costed β keeps its owner on claim, as decided.
- A negative cost is rejected at intake (
ErrNegativeCost, invalid argument). Not in the original decision, but a negative cost would mint points through the spend ledger. - SPEND entries are stored with
claimed = falseβ a ledger debit is not a claimable thing β and readers filter them by type, per the explicit- filtering decision.GetUserRewardsreturns them in the full ledger and skips them (alongside claimed rewards) in theunclaimed_onlyready-to-claim view. - The ledger read locks rows. The balance check reads the owner's reward
rows with a new transactional
FindAllByForUpdate(FOR UPDATE), so two concurrent claims serialise on the same rows rather than both passing the same balance. - The two-record transactional path lives in the mutation seam as
svc.MutateTx(Mutate with the transaction exposed) plussvc.InsertTx(Create's protocol β v7 uid, CREATE audit entry β under an existing transaction); both written records carry the usual audit trail. - The web frontend was not part of this change; it gains the balance and save-up displays separately.
Consequences
Positive
- The plan's save-up flow becomes real: costs, visible balances, an honest "10 more to go", and a blocked claim that explains itself.
- The ledger is explicit and self-reconciling: every spend is a record, every spend links to what it bought, and only the claim flow can write one.
- Balance is a plain sum over typed entries β no derived-formula drift across the web app, CLI, and any future reader.
- Fixes the ownership-stealing wrinkle in Claim as a by-product.
- Backwards compatible:
costdefaults to 0, existing rewards and clients are untouched, and SPEND is additive to the enum.
Negative / trade-offs
- The mutation seam grows a two-record transactional path β more machinery in
svc, and the first place where a handler's write is not a singleMutate. That path must carry the same locking and audit guarantees. - Every reward reader must filter deliberately (SPEND entries are not claimable, not grantable, not "rewards" in the celebratory sense); a missed filter shows a child a confusing ledger row.
- Refund-on-delete means deleting a reward silently moves points; the audit trail records it, but a parent may still be surprised.
- Per-person grants mean no shared shop; a family that wants "first to 40 points wins the trip" must grant the reward to each child (or a future ADR revisits the shop).
Alternatives considered
- Computed balance (no ledger) β balance = Ξ£ earned β Ξ£ costs of claimed rewards; no new records. Rejected: every reader must re-derive the formula, spends are invisible in history, and manual reasoning about "where did the points go" gets harder as rewards accumulate.
- Negative POINTS entries β reuse the existing type with
value < 0. Rejected: every list and sum in two frontends and the CLI must learn about negative point rewards, and blocking hand-made negatives needs a special rule anyway; a dedicated type makes the filtering explicit. - Consuming earned entries β mark specific earned rewards as spent until the cost is covered. Rejected: mutates the most records per claim for provenance nobody asked for, and is the hardest model to explain to a child.
- Family shop (unowned costed rewards) β first to afford it, claims it. Rejected for now: changes the proto's ownership shape and both frontends, and contradicts the noticeboard's per-person identity; revisit if wanted.
- UI-only enforcement β server allows negative balances. Rejected: the rule would be fiction to anyone hitting the API directly.
- Spend-then-compensate instead of a transaction β stays within the single-record seam but a crash between writes leaves a lying ledger.
- Admin-written manual spends ("deduction for the broken window") β rejected to keep the ledger self-reconciling; a deliberate future amendment could add it with its own display story.
- Refuse deletion while spent (mirroring the user live-reference rule) β rejected: with no unclaim RPC a mis-claim would be permanent, which fits an accounting system better than a family.
Amendment (2026-07-25): claimed-only balance
The balance now counts only claimed points entries:
balance = Ξ£ claimed POINTS β Ξ£ SPEND. An unclaimed points reward is "ready
to claim" and worth nothing until claimed β claiming is banking. This makes
the claim step meaningful for points (before, points counted the moment they
were granted, so claiming them changed nothing) and clears the way for
features that grant points automatically without silently moving money, such
as item auto-claim. The SPEND side is unchanged, and no data migration is
needed: existing claimed points keep counting, existing unclaimed points move
to the ready-to-claim shelf.