core: redeem badge codes and issue credentials

This commit is contained in:
shum committed 2026-08-27 10:28:26 +00:00
1 parent 05d4724d41
commit 89e9bf4be7
5 files changed
+598 -119

No files matched your search

@@ -140,7 +140,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat
| B4 | Issuer key loading and credential signing | A5, A6, B2 | ☑ |
| B5 | RPC dispatcher: envelope, version, signer, throttle | A2, A6, B1 | ☑ |
| B6 | `getBadgeCatalog` | A4, B1, B2, B5 | ☑ |
| B7 | `purchaseBadge{code}` and `issueBadge` | B1, B2, B3, B4, B5 | ☐ |
| B7 | `purchaseBadge{code}` and `issueBadge` | B1, B2, B3, B4, B5 | ☑ |
| B8 | `codes` operator subcommand | A4, B1, B3 | ☑ |
| B9 | Service address publication | A6, B5 | ☐ |
| B10 | Service integration tests | B7, B8 | ☐ |
@@ -551,7 +551,7 @@ Keep the existing `sendChatCmd cc (APISendServiceResponse …)` reply path.
#### B7 — `purchaseBadge{code}` and `issueBadge`
**Files:** `apps/simplex-badge-service/src/BadgeService/Service.hs`
**Files:** `apps/simplex-badge-service/src/BadgeService/Service.hs`, `apps/simplex-badge-service/src/BadgeService/Store.hs`, `src/Simplex/Chat/Badges/Types.hs`, `tests/Bots/BadgeServiceTests.hs`
**Do:** Signing is an IO action, so it is performed **before** any write and no transaction is held open across it.
@@ -560,14 +560,14 @@ Keep the existing `sendChatCmd cc (APISendServiceResponse …)` reply path.
3. Resolve the purchase with `getPurchaseByKey`. If it is absent, which is the normal case since C4 mints a fresh key per redemption, plan its creation for step 6 rather than writing it here; nothing is written before the signature. A repeated key is only produced by a non-standard client: credit the months to that purchase's existing ledger, write no second purchase row, and return `bad_request` if the code's `badge_type` differs from that purchase's, until tier upgrades land (§6).
4. Compute the prospective ledger state in memory with B2's pure functions: `advance now`, then `credit now months (SCPayment Nothing)`, then `issue now`. A purchase absent in step 3 has no ledger entry to read, so its state is B2's `initialLedgerState now` seeded with the code's `badge_type`. `invoiceId` is absent for code payments (`Badges/Service.hs:162`). `advance` may yield one `debit(lapse)` entry; it belongs to the write set, not to a computation that is discarded.
`issue` returns `Nothing` in two cases, and the answer also depends on the command. With a zero balance, which only `issueBadge` reaches since every code credits at least one month (B8), go to step 6 if `advance` produced a `debit(lapse)`, writing that entry alone, and otherwise straight to step 7. When step 6 runs, the `statement` is read back inside its transaction; when nothing is written, it is read in a single read transaction. Either way it never shows a balance the database does not hold. With a positive balance the current month is already issued. Take `balanceStartTs` from the state `advance` left, and fetch the credential for the period `[addMonths (-1) balanceStartTs, balanceStartTs)` with B1's `getIssuanceForPeriod`. That period is the current month because the previous `issue` moved `balanceStartTs` to the start of the next unissued month, which is past `now`; `advance` therefore returns `Nothing` here. For `issueBadge` there is nothing to record, so return it and write nothing (RPC §Idempotency). For `purchaseBadge{code}` the credit must still be recorded, so go to step 6 with the fetched credential in place of a fresh signature and with neither a `debit(badge)` entry nor an issuance row to write, since that month's issuance and its debit already exist and B2 property 3 keeps them 1:1; only the code redemption, the payment row, any `debit(lapse)` and the `credit(payment)` entry are recorded. Neither case reaches step 5.
`issue` returns `Nothing` in two cases, and the answer also depends on the command. With a zero balance, which only `issueBadge` reaches since every code credits at least one month (B8), go to step 6 if `advance` produced a `debit(lapse)`, writing that entry alone, and otherwise straight to step 7. When step 6 runs, the `statement` is read back inside its transaction; when nothing is written, it is read in a single read transaction. Either way it never shows a balance the database does not hold. With a positive balance the current month is already issued. Fetch its credential with B1's `getIssuanceForPeriod` probed **at `now`**, not at `addMonths (-1) balanceStartTs` (§9): `now` is inside that period because the previous `issue` moved `balanceStartTs` to the start of the next unissued month, which is past `now`, while its own period start is at or before the instant that issue ran; `advance` therefore returns `Nothing` here. For `issueBadge` there is nothing to record, so return it and write nothing (RPC §Idempotency). For `purchaseBadge{code}` the credit must still be recorded, so go to step 6 with the fetched credential in place of a fresh signature and with neither a `debit(badge)` entry nor an issuance row to write, since that month's issuance and its debit already exist and B2 property 3 keeps them 1:1; only the code redemption, the payment row, any `debit(lapse)` and the `credit(payment)` entry are recorded. Neither case reaches step 5.
5. Sign the resulting period with B4. **A signing failure returns `internal` and writes nothing**; the code stays unredeemed and the client may retry it.
6. No write happens before a signature succeeds or step 4 proves one unnecessary. Then open one transaction and write, in order: the `badge_purchases` row if absent, through `createPurchase`; the `payments` row through `createCodePayment`; the `debit(lapse)` entry `advance` produced in step 4, if any; the `credit(payment)` entry; the `debit(badge)` entry; the issuance row carrying the signed credential; and `redeemed_purchase_id` with `redeemed_at` on the code. A conflict on the code's redemption columns aborts the transaction and re-classifies from step 1.
6. No write happens before a signature succeeds or step 4 proves one unnecessary. Then open one transaction and write, in order: the `badge_purchases` row if absent, through `createPurchase`; the `payments` row through `createCodePayment`, pointed at by the purchase through `attachPurchasePayment` only when the purchase has no payment yet (§9); the `debit(lapse)` entry `advance` produced in step 4, if any; the `credit(payment)` entry; the `debit(badge)` entry; the issuance row carrying the signed credential; and `redeemed_purchase_id` with `redeemed_at` on the code. A conflict on the code's redemption columns aborts the transaction and re-classifies from step 1.
`@payments` has no `price_id` or `offer_id` columns (`M20260731_user_badges.hs:42-56`), so core §5's "price_id and offer_id NULL" does not apply. A code purchase writes no `@badge_invoices` row.
7. Respond `BSPBadgeCredential {credential, receipt, statement}` with `receipt = Nothing`. The receipt is the transfer instrument for unissued months and belongs to the `SPReceipt` payment this plan defers (§6); the service writes no `receipt_hash` (A3) and C4 stores nothing for it. An exhausted balance is not an error: `credential = Nothing`, with the statement showing the zero balance.
`issueBadge` performs steps 4 to 6 against an existing balance, with no code involved. It is the only command that re-issues, and C3's worker is its only caller.
`issueBadge` performs steps 4 to 6 against an existing balance, with no code involved. It is the only command that re-issues, and C3's worker is its only caller. It is also the only command carrying an asserted cursor (`balance.lastEntry.entryId`): resolve it against this purchase's ledger and return the entries after it with `previousEntryId` echoing it, or the complete history when it names nothing (§9).
**Verify:** covered by B10.
@@ -1342,16 +1342,27 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
- `relay_request_execute_at`'s default, which the dump renders in the server's timezone: `'1970-01-01 01:00:00+01'` in the committed file versus `'1970-01-01 00:00:00+00'` here. The same instant; the committed rendering was kept to avoid a spurious flip.
- **The Postgres schema-dump spec cannot pass on Linux as written. Pre-existing, unrelated to badges.** Two independent causes, and together they explain why this dump had drifted. First, `tests/PostgresSchemaDump.hs:71` selects `sed -i ''` — BSD/macOS syntax — unless `envCI` is true, and `envCI` is `lookupEnv "CI" == Just "true"` (`tests/ChatTests/Utils.hs:117`). A developer on Linux running locally, without `CI=true`, gets the macOS branch and the spec dies on `sed: can't read /^--/d`. The flag conflates "running in CI" with "has GNU sed". Second, even with `CI=true`, the spec compares a freshly dumped schema against the committed file without stripping `\restrict`/`\unrestrict`, so any patched `pg_dump` fails the comparison non-deterministically. Fixing either is outside this plan; note both before relying on that spec.
- **A2 — three more `Int64` id fields need the same correction the step already mandates.** The step lists `BadgePurchase.paymentId`, `BadgePayment.paymentId` and `BadgeIssuance.issuanceId`. An audit of `badges-rpc.schema.json` against all four modules found the same defect in three further places, all `TEXT` columns typed as `Int64`: `StatementCreditType.SCCharge {chargeId}` (`Badges/Service.hs:163`, against `subscription_charges.charge_id TEXT NOT NULL PRIMARY KEY`), and `BadgeCharge.chargeId` and `BadgeCharge.paymentId` (`Badges/Types.hs:163-164`). `SCCharge` is the load-bearing one: it is a wire type whose `taggedObjectJSON` instance A2 writes, the schema declares `chargeId` as `string` (`badges-rpc.schema.json:230`), and Aeson would encode an `Int64` as a JSON number — so leaving it ships a payload that fails its own schema. Its sibling `SCPayment` already carries `Maybe InvoiceId`, a newtype over `Text`. A2 corrects all six.
- **`LedgerCreditType.CTPayment {invoiceId :: Int64}` and `CTCharge {chargeId :: Int64}` are wrong against their columns but are marked `-- confirmed`. OPEN — needs a decision, not a mechanical fix.** `invoices.invoice_id` and `subscription_charges.charge_id` are both `TEXT`. These are the DB-side twins of the wire types above, and `CTTransferIn {fromPurchaseId :: Maybe Int64}` beside them is correct because `from_purchase_id` really is `INTEGER`. A2 does not touch them: they sit outside its tagged-sum list, and altering a type someone marked confirmed is above a mechanical step. C1's `insertLedgerEntries` is the first code that would persist them, so this must be settled before C1.
- **`LedgerCreditType.CTPayment {invoiceId :: Int64}` and `CTCharge {chargeId :: Int64}` are wrong against their columns but are marked `-- confirmed`. `CTPayment` RESOLVED by B7 below; `CTCharge` still OPEN — needs a decision, not a mechanical fix.** `invoices.invoice_id` and `subscription_charges.charge_id` are both `TEXT`. These are the DB-side twins of the wire types above, and `CTTransferIn {fromPurchaseId :: Maybe Int64}` beside them is correct because `from_purchase_id` really is `INTEGER`. A2 does not touch them: they sit outside its tagged-sum list, and altering a type someone marked confirmed is above a mechanical step. C1's `insertLedgerEntries` is the first code that would persist them, so this must be settled before C1.
- **A4 — `offerTotal` calls `error` on an impossible offer, which B6 and D4 must not let reach a request thread.** `chargeableMonths` (`BadgeService/Catalog.hs`) rejects `freeMonths >= months` with `error` rather than wrapping a `Word8` subtraction, and `seedCatalog` forces it at startup so a bad catalog kills the process before the service accepts traffic. That fences it for Phase A, where `seedCatalog` is the only writer. It stops being fenced the moment `offerTotal`/`catalogTotals` run inside request handling over rows read from the database, which is B6 (`getBadgeCatalog`) and D4 (`/api/catalog`). The bot's `processQueuedRequests` is a single-threaded `forever` loop (`BadgeService/Service.hs:96-99`), so an uncaught `error` there would take the whole service down for every user rather than failing one request — strictly worse than the mispricing the guard prevents. Before B6, either give `BadgeOffer` a smart constructor so `freeMonths >= months` is unrepresentable, or catch at the request boundary so the blast radius is one response.
- **B6 — resolves the A4 `offerTotal`/`error` hazard above with a third option: a typed absence, not either option this plan named.** Neither a smart constructor (would change `BadgeOffer`'s shape and every construction site, including the seeded defaults) nor a request-boundary catch (relies on `runHandler`'s catch-all reaching every caller, including D4's future HTTP path, which does not share that catch-all) was taken. Instead `chargeableMonths :: Word8 -> Word8 -> Maybe Word8` and `offerTotal :: BadgePrice -> Maybe BadgeOffer -> Maybe CurrencyAmount` (`BadgeService/Catalog.hs`): `freeMonths >= months` now answers `Nothing`, which A2 already defines on the wire as "no computed total, render unavailable" — so the blast radius of one bad row is one offer, not one response and not one process, and the fix is shared by every caller of `offerTotal`, present or future, not just ones inside a `catch`. `seedCatalog` still fails the process at startup, by name (`requireTotal`), for a bad *default* catalog, so the Phase A guarantee is unchanged. `handleGetBadgeCatalog` additionally logs (`logUnpricedOffers`) when a *database* row reaches this state, since nothing else would ever say why a live offer reads as unavailable. **Fix round 1** found the same hazard on `ODDiscount`'s sibling arm, which the original pass missed: `100 - percent` on a bare `Word8` wraps for `percent > 100` (a `Store.decodeDiscount` value is not range-checked on the way in), producing a 2.55x *overcharge* rather than a crash — worse than the `freeMonths` case, since it inflates the price instead of merely reading as unavailable. `discountedPercent :: Word8 -> Maybe Word8` closes it the same way, `percent > 100 -> Nothing`.
- **B6 fix round 1 — `seedCatalog`'s `requireTotal` is stricter than the `forceTotal` it replaced: an unpinned offer's `total = Nothing` is now also rejected at startup, not just an unchargeable one.** The predecessor (`forceTotal`, pre-B6) accepted `total = Nothing` unconditionally — its Haddock read "an offer whose price isn't found... gets `total = Nothing` rather than a crash, same as an unpinned offer" — because at the time nothing distinguished "unpinned" from "not chargeable" and neither was thought worth failing startup over. B6's `requireTotal` rejects `total = Nothing` for either reason: correct for today's `defaultCatalog` (every seeded offer is pinned, so only "not chargeable" is reachable), but a real behaviour change per §4 rule 6 — a future unpinned *default* offer would now fail the service at startup rather than silently seed with no total. Recorded rather than re-litigated, since the stricter behaviour is the one actually wanted (an unpinned default offer is exactly the kind of bad row `seedCatalog` should catch by name, not seed silently).
- **B6 — `BadgeService/Config.hs` is touched, outside the step's stated file list (`Service.hs`, `tests/Bots/BadgeServiceTests.hs`).** `takeCatalogBucket` (the peek-and-debit STM action for the unsigned-`getBadgeCatalog` bucket B5 added but never wired) had to be added there to satisfy the Verify line's throttle case — B5 built the bucket itself but not a way to spend from it, and that action belongs beside `checkFailureBuckets`/`debitFailureBuckets`, the other bucket operations, not in `Service.hs`. §4 rule 6 was applied to the `Catalog.hs` deviation (the two entries above) but missed this one in the original pass; recorded here on the same rule.
- **§4 — the stated build command did not work; corrected in place.** `cabal build simplex-chat simplex-badge-service` fails with `Ambiguous target 'simplex-chat'`, because `simplex-chat` names both a library and an executable component. It is now `cabal build lib:simplex-chat exe:simplex-chat simplex-badge-service`, which was run and succeeds. The test command beside it was correct as written and passes: 41 examples, 0 failures across both `Supporter badges` and `Badge service`.
- **B1 — two vocabularies are now spelled only in `BadgeService/Store.hs`, with nothing tying them to a future codec.** The ledger's `entry_credit_type`/`entry_debit_type` values (`support`, `opening`, `transfer_in`, …) and the payment provider literal `code` are written as bare strings, because no `ToField`/`FromField` instance exists for `LedgerCreditType`, `LedgerDebitType` or `PaymentProvider` anywhere in the repo. Neither is a *duplicate* spelling today, so neither is a defect. Both become one the moment a later step adds a codec: D0, E2 and F1 need a real `PaymentProvider` encoding, and whoever resolves the `LedgerCreditType` question above will need the entry-type spellings to match what B1 already wrote. Reuse these spellings rather than inventing a second set, and prefer deriving both directions from one instance, as `BadgePurchaseStatus` and `BadgeItemStatus` do.
- **B5 — the per-signer bucket sweeper is built and tested but nothing schedules it. B7 must wire it to a timer. OPEN.** The original hazard is closed: `peekSignerBucket` no longer creates an entry for an unseen key, so a freshly minted keypair costs nothing, and an entry is created only by a classified failure — which also spends a token from the single shared global bucket, capping new entries per window at that bucket's capacity however many keys an attacker mints. `sweepSignerBuckets` evicts recovered entries and `sweepSignerBucketsIO` takes the injectable clock so eviction is provable without sleeping. What is missing is a caller: nothing runs the sweep on an interval, so the map only shrinks when something asks it to. This costs nothing during B5, where `debitFailureBuckets` has no caller at all and the map is provably empty on every reachable path. **B7 is the first step that makes a redemption fail, so B7 owns wiring the sweep onto a timer** — a third arm of the service's `raceAny_` alongside the bot and the reconciliation pass is the natural place.
- **B5 — the per-signer bucket sweeper is built and tested but nothing schedules it. B7 must wire it to a timer. RESOLVED by B7 below.** The original hazard is closed: `peekSignerBucket` no longer creates an entry for an unseen key, so a freshly minted keypair costs nothing, and an entry is created only by a classified failure — which also spends a token from the single shared global bucket, capping new entries per window at that bucket's capacity however many keys an attacker mints. `sweepSignerBuckets` evicts recovered entries and `sweepSignerBucketsIO` takes the injectable clock so eviction is provable without sleeping. What is missing is a caller: nothing runs the sweep on an interval, so the map only shrinks when something asks it to. This costs nothing during B5, where `debitFailureBuckets` has no caller at all and the map is provably empty on every reachable path. **B7 is the first step that makes a redemption fail, so B7 owns wiring the sweep onto a timer** — a third arm of the service's `raceAny_` alongside the bot and the reconciliation pass is the natural place.
- **A6 — a duplicated ini section or key is silently accepted, keeping only one of the two. OPEN.** `Data.Ini`'s `parseIni` is `parseOnly iniParser` over a `many`-based parser, so it essentially cannot fail on malformed-but-textual input: `many` never fails and `parseOnly` does not require full input consumption, so trailing garbage is dropped without error. `readIniFile` therefore returns `Left` only on I/O errors. Two consequences the config parser does not check for. A repeated `[section]` header discards the earlier block entirely, because `iniSections` is a `HashMap.fromList` and the last value wins; a repeated key within one section keeps the first, because the lookup takes the first match. If the surviving block is itself valid, the service starts with a silently wrong configuration — including, for instance, a secret-file path from the wrong block. Add a duplicate-section and duplicate-key check to `Config.hs`, and until then say so in the operator documentation at H5.
- **A6 — `seedCatalog` calls `getCurrentTime` directly, against the rule that only `BadgeServiceEnv.now` reads the clock.** This is structurally forced rather than an oversight: `seedCatalog` runs before `newBadgeServiceEnv` exists, since the env is built after migrations and seeding. Either seed later, or give `seedCatalog` a clock parameter, whenever a step first needs to control service time across startup.
- **B7 — `CTPayment` is now `{paymentId :: Text}`, closing the open item above.** The column it persists through is `badge_ledger.payment_id TEXT REFERENCES @payments` (`M20260731_user_badges.hs:148`), and `@payments.payment_id` is a `TEXT` primary key (`:43`) — so the field is the *payment's* id, not the invoice's, and `Text` is the only type that fits. `BadgeService.Store`'s `encodeLedgerEntryType`/`decodeLedgerEntryType` now persist and read `credit`/`payment` rows instead of refusing them, and B6's `statementEntryType` converts one to `SCPayment {invoiceId = Nothing}` instead of refusing it. The wire field is the *invoice's* id, which a code payment does not have (B7 step 4), so `Nothing` is correct for every payment this milestone writes. It stops being correct for an invoice-funded payment: `payments.invoice_id` is the join that resolves it, which `statementEntryType` (pure, connection-free) cannot do — **whichever step first credits an invoice-funded payment must resolve the invoice id before the entry reaches the statement.** `CTCharge` is untouched and still refused in both directions: subscriptions are out of scope (§6), so nothing writes one.
- **B7 — the per-signer bucket sweep now runs on a timer, closing B5's open item above.** `sweepSignerBucketsLoop` is a third arm of `raceAny_` in **both** entry points — `badgeService` (the default, which the test harness and the shipped binary use, and which had no `raceAny_` at all before: its event loop moved into `processServiceEvents`) and `badgeServiceCLI`. The interval is a named constant, `signerBucketSweepIntervalSeconds = 600`, and it is real time (`threadDelay`), not `BadgeServiceEnv.now`: it schedules the sweep rather than deciding anything, and the eviction itself still reads the injectable clock through `sweepSignerBucketsIO`, so B10 proves eviction by calling that directly rather than waiting on the loop.
- **B7 — the already-issued month's credential is fetched by probing `getIssuanceForPeriod` at `now`, not at `addMonths (-1) balanceStartTs` as step 4 said.** `addMonths` is deliberately not additive under clamping (`Badges/Months.hs`: 31 Jan + 1 month = 28 Feb, and 28 Feb − 1 month = 28 **Jan**), so stepping a month back from a clamped period boundary can land *before* the period it came from and match the issuance for the month before — returning the wrong credential, not merely none. `now` is inside the period by construction (`balanceStartTs > now` is why `issue` returned `Nothing`, and the period's start is at or before the instant that issue ran, hence at or before `now`), so it names exactly one issuance with no arithmetic at all. Step 4 corrected in place.
- **B7 — B1's `createCodePayment` is split into `createCodePayment` (the `@payments` row) and `attachPurchasePayment` (the purchase's pointer).** Step 3 requires a second code redeemed under a key that already has a purchase to credit that purchase's existing ledger, and step 6 requires the `payments` row for the `credit(payment)` entry to reference — but `@badge_purchases.payment_id` is `UNIQUE` and holds at most one payment (`M20260731_user_badges.hs:99,104`), so the combined function could only answer `SEPaymentConflict` there. The second code now gets its own `payments` row while the purchase's pointer stays on the first. `createCodePayment` had no caller before B7, so nothing else changed. Step 6 corrected in place.
- **B7 — `markCodeRedeemed` is now guarded and a new `SECodeConflict` exists.** Step 6's "a conflict on the code's redemption columns aborts the transaction and re-classifies from step 1" had nothing to detect a conflict with: B1's `UPDATE` was unguarded and would have silently overwritten another key's redemption. It is now `WHERE code_hash = ? AND redeemed_purchase_id IS NULL`, with an existence check separating `SECodeNotFound` from `SECodeConflict`. The handler retries the whole redemption exactly once on `SECodeConflict` (`redemptionAttempts = 1`); the second pass is terminal, because a redeemed code cannot become unredeemed by itself.
- **B7 — a `badgeRequest` naming a different tier than the funding is refused with `bad_request`. Not in the step, and it is a security boundary.** RPC "Commands": "the service signs exactly this content or rejects the command". Without the check, a client could present a `supporter` code (or hold a `supporter` balance) and be handed a signed **`legend`** credential, since `issueSignedBadge` overrides only `badgeExpiry` and signs the `badgeInfo` it is given. `purchaseBadge{code}` now requires `badgeRequest.badgeInfo.badgeType` to equal the code's `badge_type`, and `issueBadge` requires it to equal the purchase's `current_badge_type`.
- **B7 — `purchaseBadge` carrying an `upgrade` is `bad_request`, before the payment is looked at.** `BSCPurchaseBadge.upgrade` is the store one-time upgrade (RPC "Upgrades"), which needs store evidence, and tier upgrades are out of scope (§6). Ignoring the field would consume the code while silently dropping what the client asked for.
- **B7 — `issueBadge` honours the asserted cursor, closing B6's "`previousEntryId` cannot be populated" gap.** `issueBadge` is the only implemented command carrying `balance.lastEntry.entryId`. A new store function, `getLedgerEntryIdByUuid`, resolves that wire uuid to the local `entry_id`, scoped to the purchase so an asserted uuid cannot probe another purchase's ledger; `purchaseStatement` now takes a `Maybe StatementCursor` carrying both halves, so `previousEntryId` always echoes the value the client actually sent and is never spelled independently of the id the query runs on. An assertion naming nothing yields the complete history, which is the RPC's other permitted answer; its third — one `opening` credit restating the balance — needs opening entries, which nothing in this milestone writes. `purchaseStatement` also takes the `badge_purchase_id` rather than the whole `BadgePurchaseRow`, since the replay path has only the id. `getBadgeCatalog` and `purchaseBadge` carry no cursor and pass `Nothing`.
- **B7 — the same-key replay path does heal the ledger, which step 2's "writes nothing" reads as forbidding.** "Writes nothing" is kept for everything the redemption would record — no second purchase, payment, credit, debit, issuance or redemption — but the statement is still read through `purchaseStatement`, which appends one `debit(lapse)` when months have genuinely lapsed since. The RPC has the service heal its own ledger before answering any statement ("Statement and balance"), B6's read-only `getBadgeCatalog` already does exactly this, and the alternative is telling the client a balance the database does not hold. A replay immediately after a timeout — the case §Idempotency is about — lapses nothing and so writes nothing.
- **B7 — two transactions per command, not one.** "One transaction per command" is kept for *writing*: the classification and the plan are read in a transaction that writes nothing, signing happens with no transaction open (which the step requires), and one further transaction does every write and reads the statement back inside it. `IssueCached` adds a third, read-only, to fetch the cached credential. A command with nothing to write opens exactly one, and writes nothing in it.
- **B7 — an existing B5 test's expected code changed.** `testBadgeServicePurchaseBadgeUnknownKeyIsNotUnknownPurchaseKey` asserted `internal` because `purchaseBadge` was unimplemented; `"UNKNOWN-CODE"` now reaches the classifier, normalizes to 11 characters and fails the check character, so it is `code_invalid`. Its load-bearing assertion — never `unknown_purchase_key` — is unchanged. B10 replaces the surrounding suite.
- **A1 — `chat_lint.sql` gains 5 fkey-index advisories, left unfixed by design.** The badge migration introduces unindexed foreign keys: `badge_invoices.offer_id`, `badge_invoices.price_id`, `badge_offers.price_id`, `badge_issuances.entry_id`, `users.shown_badge_id`. The lint output is committed literally rather than adding indexes, since index design is outside A1's scope and the repo has precedent for this (`9e000d6bc`). The first three point at rarely-mutated reference tables. The last two are the ones likely to matter under load — `badge_issuances.entry_id` for issuance lookup by ledger entry, and `users.shown_badge_id` for per-user badge display (C1's `getShownPurchase`). Decide on indexes for those two before release.
## 10. End-to-end verification