# Tessera's code paths: security review

Reviewed 9 November 2026 (plan TE, slice TE4), against the code on `develop` after TE3. Plan TE's last slice asks for a fresh reader's
review of what TE1 to TE3 built: voucher campaigns, issuing, checking and redeeming once, guessing kept out, batches and printing, and the
console. A dated record: review again before the first real campaign (a pilot school's admissions, or GWCL), and whenever the code format,
the redemption, or how codes are kept changes.

**Method.** Two adversarial reads, in parallel, by reviewers who hadn't written the code and were given its intent and its files, not an
opinion of it: one of issuing, checking and redeeming (the code, guessing, ownership and isolation, voiding, the audit, the API's
answers, and races in them), one of batches, printing and the console (the background job, the keep window, the PDF and CSV, the
console's pages and changes). Each finding was checked against the code before it was accepted, and every accepted finding has its test
in `tests/Feature/Vouchers/ReviewTest.php`, named for it. The reviewers scored the paths as they found them: redeeming **7 / 10**, batches
and the console **6.5 / 10**.

**Verdict.** **Ready for a pilot campaign.** The core guarantees held from the start: one conditional update decides a redemption, so two
at one moment have one winner; codes are never in plain text, in the table, a log, the idempotency store, a webhook or a page; another
organisation's or product's code answers exactly as one that never was; a bound code's holder is checked before anything about the code is
said. The review found two serious faults: a batch slipped past the organisation's monthly limit, since it was counted only when its codes
were made, and the console's search put a code someone typed into a page address, where access logs keep it. Both are fixed, with every
lesser finding but one, accepted below.

---

## Scoring scale

As the foundation's: 1–3 unsafe for production with personal data; 4–6 staging or a friendly pilot; 7–8 production-ready with documented
caveats; 9–10 enterprise-grade with formal compliance.

## What was found, and what was done

| # | Finding | Severity | Done |
|---|---|---|---|
| redeem 1, batch 1 | A batch was counted against the month's limit only as its codes were made, so two batches asked at once both passed | Serious | Reserved codes count: single codes as made, every batch's whole size when asked for |
| batch 2 | The console's "find a code" was a GET: a code typed from a card landed in the address, and so in access logs | Serious | A POST; the GET answers 405 |
| redeem 2 | The hourly ceiling on misses was a server's, across every organisation: a guesser at one school shut every school out | Moderate | Counted for one organisation on one product; once past it, only sources that have missed wait, an hour from their last miss, and a right code still gets in; the first trip each hour is logged |
| batch 3 | Each card fitted and wrapped the campaign's words again: 50,000 cards with long words took about two minutes | Moderate | Fitted and wrapped once a file: 5,000 cards in a fraction of a second |
| batch 4 | A batch's keep date was read from the setting each time, while the sweep used the date set at download: changing the setting split them | Moderate | One date on the batch (`kept_until`), fixed at the product's first download; a batch with no codes left answers 410 |
| batch 5 | A batch's void showed on every code in it, even one redeemed before | Moderate | One moment for the batch and the codes it voided; the line shows only on those |
| batch 6 | A batch whose job ran out of tries was a dead end | Moderate | `vouchers:check` every 10 minutes picks up a stalled batch, and a failed one an hour later, carrying on from its last chunk |
| redeem 3 | A bound campaign's `issue_ref` given again with another holder returned the first holder's code | Minor | 422 |
| redeem 4 | Misses from one source sent at once all passed the guard before any was written | Minor | One attempt at a time from a source on a product, under a transaction advisory lock |
| redeem 5 | A redemption in the instant a campaign ended, between the app's clock and the database's, was a 500 | Minor | The database's clock decides: expired, or not yet |
| redeem 6 | Checking a used or voided code wrote a line on its history, kept for good, with no limit | Minor | A refusal is a line only when someone tried to spend the code; a check is an attempt, kept 30 days |
| redeem 7 | Two campaigns asking for one prefix at once: the second was a 500 | Minor | The unique violation becomes the validator's answer |
| redeem 8 | A source's misses were counted per server: a product with two servers gave a guesser ten | Minor | Counted across the product's servers |
| batch minors | A closed campaign's cards could be downloaded; the CSV's price cell skipped the formula guard; the Service's look started the product's keep clock; voiding twice sent the webhook twice; the download paged by id but sorted by number | Minor | 410 for a campaign that's over; every text cell guarded and a currency of letters; only the product's download starts the clock; one webhook a change; paged by number |

## Accepted

- **A prefix is unique within an organisation, across its products** (redeem 7's other half). Product B hears "taken" for product A's
  prefix in the same organisation. The organisation is the same, and a serial must name one code for its support: two products' cards
  with the same serial would be worse. Accepted.

## What holds

The redemption's one conditional update, with the campaign's dates checked by the database; voiding and redeeming excluding each other,
backed by a check; codes from `random_int`, the check character's maths (any one slip, any two swapped); keyed hashes kept apart by kind
(`code:`, `holder:`, `source:`); authentication before route binding; composite keys tying every code and batch to its campaign's
organisation; numbers reserved under the campaign's lock with a database check on `most_codes`; the job's chunks and their count in one
transaction, a clash retried in its own savepoint; only the first download starting the clock; streamed files, `no-store`, flat memory;
printing behind a fresh proof.
