Applies to: Counterfoil v0.14.0 (Modules 1–12)
Audience: the Raffle Administrator required by RCW 9.46.0323(3)(l), and the Finance/Records Officer
working alongside them.
This is the deep guide to Counterfoil, a management and recordkeeping system for Washington State enhanced raffles. It explains why the system refuses what it refuses, what each artefact is worth in front of a regulator, and exactly what you do at each phase of a $1M+ enhanced raffle — from the first configuration decision through the record set you file when the raffle closes.
1. How Counterfoil works
1.1. What the system is, and what it deliberately is not
Counterfoil is a management and recordkeeping system. Two things it will never do, because Washington law forbids them:
-
It does not sell tickets. RCW 9.46.0323(3)(b) permits sales in person, by mail, by fax and by telephone only. There is no online purchase pathway anywhere in the product — not disabled, absent. Your website may host a printable order form; the form is static content.
-
It does not pick winners. RCW 9.46.0323 prohibits drawing by random number generator or similar means. There is no RNG, no auto-draw and no simulation mode in the codebase, and the build fails if anyone adds one — an automated scan bans
Random,SecureRandom,Math.random,ThreadLocalRandomandshufflefrom the counterfoil, pool and drawing code. A draw event cannot even be stored without a human’s input string: the columns holding what was scanned are non-nullable, so a draw the system produced for itself is rejected by the database.
Also absent by design, so you know where the boundaries are: no payment processing or cardholder storage, no merchant or bank integration, no inbound webhook of any kind, no printer driver or spooler, no email server, no electronic filing channel to WSGC or your county, and no delete path for any record.
1.2. Five rules that explain every refusal you will meet
1. Every write is attributed. Persisting a record with no authenticated user and no system context throws. There is no anonymous write path and no null-actor escape hatch. This is why an Auditor session cannot change anything anywhere, and why retiring a user account disables it rather than deleting it — the attribution has to survive the three-year retention window.
2. Records are appended, never edited or deleted. The payment ledger, the reserve-balance log, the significant-event log and the tax-jurisdiction lookup are all append-only. A mistake is corrected by a new entry that supersedes or offsets the old one, so the record shows what was recorded and later corrected — which is what an auditor actually wants to see. Destruction after retention expiry is redaction under a permanent certificate, not deletion.
3. Time comes from one clock. No component reads the wall clock directly; they all take the injected system clock (R16). This is what makes tests deterministic and what will let Module 13 bolt on NTP monitoring without touching a single record path.
4. A significant event is written in the same transaction as the thing it describes. If the change
rolls back, so does the event. The one deliberate exception is rejections: ORDER_REJECTED,
PAYMENT_REJECTED and CONFIGURATION_LOCKED_ATTEMPT commit in their own transaction precisely
because the surrounding one rolled back, and "attempts are rejected and logged" is the acceptance
criterion.
5. Frozen artefacts and live views get different guarantees, and the difference matters.
| Kind | Examples | Guarantee |
|---|---|---|
Frozen |
receipts, counterfoil batches and manifests, Raffle Drawing Reports, finalized tax worksheets |
Byte determinism. Regenerating from unchanged data produces a byte-identical file, years later. PDF creation dates and document IDs come from the artefact’s own stored instant and content hash, never the wall clock. |
Live |
the WSGC record set, the prize-and-winner register |
Content-hash determinism. The hash covers the figures alone, excluding who generated it and when. Unchanged records ⇒ unchanged hash. |
Applying the wrong one would either promise byte identity we cannot deliver, or freeze a view that is supposed to track fulfilment as it progresses.
1.3. Roles and segregation of duties
Five roles, fixed in code; a person may hold several and their permissions are the union.
| Role | Owns | Explicitly cannot |
|---|---|---|
|
Configuration, pool closure, counterfoil generation, drawing sessions and winner recording, void/destruction approval, tax finalization |
Record payments, file prize evidence, or fulfil winners (those are Finance’s) |
|
Order entry, receipt issuance, raising void requests |
Change configuration, approve their own void, touch pools, barrels or drawings |
|
Payments, refunds, reconciliation, prize records, reserve balances, winner fulfilment, records, reports, archives, destruction requests |
Configure a raffle or run a drawing |
|
Reading everything — records, logs, reports, exports |
Anything that writes. Enforced by a test that exercises every mutating endpoint under this role and asserts rejection |
|
Accounts, sales units, the tax-jurisdiction lookup, system operations |
Edit any gaming record. This separation is a GLI-31 expectation, not an internal preference |
Two roles are permanently excluded from gaming writes — AUDITOR and SYSTEM_ADMINISTRATOR — and an
architecture test auto-discovers every new endpoint to keep it that way.
The tax-jurisdiction lookup is the one System-Administrator-writable table that touches money. It is safe because it is reference data about municipal ordinances, and because it is structurally incapable of altering a raffle: the jurisdiction and rate are snapshotted onto the raffle at configuration and frozen again onto the worksheet at filing.
1.4. The four two-person controls
| Control | Who requests | Who completes | Enforced by |
|---|---|---|---|
Void a ticket |
Sales Operator or higher |
A different Raffle Administrator or Finance/Records Officer |
Service check plus role gate |
Refund an order |
Finance/Records Officer |
A different Raffle Administrator or Finance/Records Officer |
Service check; approval writes the negative ledger entry and voids every ticket in the same transaction |
Record a winner |
Raffle Administrator |
A second authorized officer typing their own credentials in the same request |
|
Destroy records |
Finance/Records Officer |
A different Raffle Administrator or System Administrator |
Service check; every precondition is re-tested at approval, not trusted from request time |
The winner control is worth understanding in detail because it happens live, in front of an audience.
The witness authenticates in band — no session is created and no token is issued — through the same
password verification, the same lockout counter and the same per-IP rate limit as a normal sign-in.
Every possible witness failure (unknown email, wrong password, insufficient role, same person as the
requester) returns one indistinguishable answer, WITNESS_INVALID, so the endpoint cannot be used to
enumerate accounts.
Disqualifying a stub is deliberately one person. The remedy for a bad disqualification is the next draw, and requiring a second signature at the barrel would slow the one moment where speed matters.
1.5. The significant-event log
Every notable action lands in one append-only, hash-chained log (R14, GLI-31 §5.6). Each event carries
a unique id, its type, a UTC and a local timestamp, the acting user (or system), the affected entity
type and id, a before/after summary for mutations, and the hash of the preceding event.
1.5.1. The 28 declared types
The full GLI-31 §5.6 catalog is declared up front; each type is emitted by the component that owns the underlying action. 22 are emitted today; the 6 marked (Module 13) are declared but not yet raised — backups, restores, clock adjustment and software verification all belong to the unbuilt integrity module. Declaring them early is deliberate: a lab reads the catalog, and a type appearing for the first time mid-certification looks like a gap that was found rather than a plan that was followed.
| Type | When it is produced |
|---|---|
|
An account reaches the failure threshold (default 5 consecutive failures) and locks. Emitted in the same act that flips the status; only a System Administrator can unlock. |
|
(Module 13) A scheduled backup fails, or its verification does. |
|
(Module 13) A scheduled backup completes and its test-restore or checksum validation passes. |
|
(Module 13) The system clock is adjusted manually, or drifts past the configured threshold (R16). |
|
A raffle, pricing plan, drawing or prize is created or edited while still |
|
The first ticket is issued and the raffle moves |
|
Someone tries to mutate a locked raffle’s configuration. Committed in its own transaction, because the attempt itself rolls back and would otherwise take the evidence with it. |
|
A drawing session closes — in the same transaction that generates the Raffle Drawing Report, so there is never one without the other. |
|
A drawing session opens. Carries the frozen gate result, including the R11 reserve position and any override reason. |
|
Chain verification finds a break. The verifier raises this about the log it is verifying — which is why the chain check is itself auditable. |
|
Chain verification completes intact, at startup and on the nightly schedule. |
|
Any report fetched through the catalogue, the WSGC record set, a finalized tax worksheet, the prize-and-winner register, or an archive export. Carries the content or manifest hash — never a purchaser name and never the archive passphrase. |
|
A sign-in attempt fails. The event records the attempt; the response still refuses to say whether it was the email or the password, so the log is evidence without the API becoming an enumeration oracle. |
|
A session is created. |
|
An order is refused — |
|
A payment is refused, including |
|
An authenticated user is refused by role. Emitted from the shared forbidden-response mapper, so it covers every endpoint automatically, including ones added later. |
|
An email delivery exhausts its attempts or hard-bounces, or a mail print batch is abandoned. This type exists because R9 is a delivery obligation — a silent non-delivery is the failure mode that matters. |
|
The broad create type. Users, sales units, purchasers, orders, draw-number issuance, ticket-series provisioning, void requests, payments, refund requests, receipts and deliveries, mail print batches, counterfoil batches and print runs, pool closures, draw events, prize records, reserve balances, pricing plans, drawings and prizes. Issuance carries the draw-number range. |
|
The broad modify type. Purchaser, order, user and sales-unit edits; a void request rejected; payment and refund state changes; a receipt reissue and delivery-state transitions; print batch and run confirmation or abandonment; drawing-session state; draw-event award and disqualification bookkeeping; fulfilment transitions; the destruction-request lifecycle; and nightly integrity-sweep findings. |
|
An approved ticket void. A pending request does not emit it; the approval does, carrying both the requester and the approver. |
|
A destruction executes. The newest type in the product and the only one covering an irreversible, data-losing operation — what has this system destroyed, when, and on whose authority has to be answerable from a single type filter. |
|
A Raffle Drawing Report at session close; a counterfoil manifest or a manifest-vs-pool verification
(an unbalanced one is this event carrying |
|
(Module 13) A restore is performed; the restored system is expected to pass chain verification. |
|
(Module 13) Deployed artefacts do not match the release signatures. Will block entry into any drawing session (R16). |
|
(Module 13) Deployed artefacts match the signed, released build, on startup or on demand. |
|
The server boots, before chain verification runs. |
|
A draw event is confirmed as a winner under the two-person control. Carries both officers, who the database has already guaranteed are different people. |
RECORD_CREATE and RECORD_MODIFY are deliberately broad rather than split per module: the entity
type and id are what you filter on, and a type-per-table catalog would grow with every release and be
useless to a lab reading it as a fixed list.
Two properties you will be asked about:
-
No API, UI or role can update or delete an event row. The store is trigger-protected at the database as well as closed at the service.
-
Personal data is masked. Event summaries carry masked names, phones and emails plus city/state — never a street line, never a full postal code, and never twenty thousand purchaser names, which in an un-deletable log would be a retention defect no role could undo.
Search it with cfoil events search (filter by date range, event type, user, entity type and id) and
verify the chain with cfoil events verify. A broken chain raises its own significant event.
1.6. The three configuration files
Counterfoil holds the licensee and the records custodian in hashed JSON files, not tables, because one server serves one legal entity forever and a one-row table models a cardinality that does not exist. A file also cannot be edited by a role — its history lives in the append-only event log.
| File | Env var | Gates | Permanent? |
|---|---|---|---|
|
|
The whole product. No licensee profile ⇒ no raffle can issue its first ticket ⇒ nothing can be sold |
Yes — read on every boot |
|
|
Filing, archiving and destroying — and nothing else. Ticket sales are unaffected by its absence |
Yes — read on every boot |
bootstrap admin |
|
Seeds the first System Administrator when |
No — delete it after first boot |
The asymmetry in records.json is deliberate and is asserted by test: losing your custodian
configuration should not stop the raffle selling, but it must stop you filing. Because nothing else
notices its absence, a smoke test will not catch a missing records.json — check
cfoil records-profile show explicitly.
The two files are kept separate on purpose. organization.json is frozen onto every raffle at its
pre-sale lock, so receipts print the licensee as it stood when selling began. Custodian data has the
opposite lifetime — it must stay live across a three-year retention window and today’s filing must
name today’s custodian. Merging them would freeze stale custodian data onto locked raffles.
records.json must name state: WA. R12 requires records maintained in Washington, and the server
refuses to start if it says otherwise, naming the field.
1.7. Errors
Every failure returns the same envelope:
{ "error": { "code": "POOL_CUTOFF_NOT_REACHED", "message": "…", "rule": "R5", "details": { } } }
code is stable and safe to branch on; rule appears when a regulatory constraint is the reason.
cfoil exits 10 for a rejected request, 13 for forbidden, 20 for auth required, 30 for
transport failure, 2 for a usage error.
2. Running the raffle, phase by phase
2.1. Phase 0 — Before anything
cfoil organization # licensee on file?
cfoil records-profile show # custodian, records location, fiscal-year end
cfoil sales-units create --code WS-FRONT-1 --name "Front desk"
Every station or device that submits an order must be registered as a sales unit first, so every order traces to where it was taken. This is also the expansion point for dedicated sales units (L2) and call-centre vendor feeds (L1) later: they become additional registered clients of the same API, not new code paths.
2.2. Phase 1 — Configure the raffle, and understand the lock
cfoil raffles create --name "2026 Enhanced Raffle" --license-number ER-2026-0104 ...
cfoil raffles pricing-plans add --raffle 7 --label "Single" --ticket-count 1 --price 100.00
cfoil raffles pricing-plans add --raffle 7 --label "3 for $250" --ticket-count 3 --price 250.00
cfoil raffles drawings add --raffle 7 --type EARLY_BIRD ...
cfoil raffles drawings add --raffle 7 --type GRAND ...
cfoil raffles prizes add --raffle 7 --drawing 19 ...
cfoil raffles checklist --raffle 7
What is validated as you type:
| Rule | Check |
|---|---|
R3 |
No ticket priced above $250. Bundles are validated per bundle: bundle price ÷ ticket count is stored as the discounted per-ticket rate |
R4 |
Grand prize value ≤ $10,000,000 |
R10 |
A percentage-of-receipts prize cannot be saved without a disclosed minimum |
R19 |
Local gambling-tax rate capped at 5%. The system suggests a rate from the jurisdiction lookup; you may override it, and the record captures the jurisdiction, the suggested rate, the effective rate and whether you overrode it |
Drawing types: GRAND, EARLY_BIRD, MULTIPLE_TICKET. REFER_A_FRIEND is defined but not
selectable in v1 and returns FEATURE_NOT_AVAILABLE — reserved so adding it later (L4) does not
migrate a live raffle.
The pre-sale compliance checklist (cfoil raffles checklist) is pass/fail on LICENSE,
ORGANIZATION, R3, R4, R5, R10 and R18. Run it days before sales day. The two most common day-one
failures — no WSGC licence number recorded, and no licensee profile on file — both surface here rather
than at the counter, and both would otherwise refuse the first sale.
2.2.1. The lock
The configuration locks irreversibly the moment the first ticket is issued, and there is no unlock in any role. The trigger is exactly one thing: draw-number issuance calls the lock as its first step. There is no manual-lock endpoint and no way to move a raffle back to draft.
After the lock, every mutating configuration endpoint returns CONFIGURATION_LOCKED and files a
CONFIGURATION_LOCKED_ATTEMPT event. One exception: the local tax rate stays editable, because it
is a reporting parameter and not a gambling eligibility standard — until you finalize the tax
worksheet, which freezes it too (TAX_RATE_FINALIZED).
The lock also freezes the licensee profile onto the raffle. Editing organization.json afterwards
affects only raffles that have not started selling — and it means an archived raffle names its own
licensee correctly with no configuration file present at all.
2.3. Phase 2 — Take orders
cfoil raffles orders preview --raffle 7 --line 3:2 # quote first; takes no lock, writes nothing
cfoil raffles orders place --raffle 7 ...
cfoil raffles orders list --raffle 7 --payment-status UNPAID
Sales channel is required and is exactly one of IN_PERSON, MAIL, FAX, TELEPHONE (R1).
Purchaser rules: state must be WA with a Washington postal code (P1 — a v1 policy, and a code seam
rather than a config toggle, so relaxing it for L6 is a deliberate re-release, not a checkbox); an
affirmative 18+ attestation is required and is stored with the attesting operator’s id and timestamp
(R8). Orders are accepted only inside the configured sales window.
You never type a price. No order request field anywhere in the system accepts money. The server prices from the locked plan and snapshots the plan label, ticket count, price and per-ticket rate onto each order line — because R18 requires the discounted rate recorded against the sale, and because the record set must be reproducible years later from stored data alone.
An order is born complete. There is no draft order and no reservation: the purchaser, the order, its lines, the ticket-series provisioning and all of its consecutive draw numbers are one transaction. Any failure rolls the whole thing back, including the number counter.
Replays are safe and the safety is mandatory. Every submit carries a client-generated
clientRequestId, unique per sales unit. Retry the same submit and you get the original order back.
This is not optional convenience: undoing a duplicate mint costs a two-person void per ticket and the
numbers are gone forever, because a draw number is never freed or reused.
Duplicate purchasers prompt, they do not block. A matching name and postal code returns
PURCHASER_MATCH_FOUND with candidates; you either reuse the existing purchaser or acknowledge the
duplicate, and the acknowledgement is stored as evidence the prompt was answered.
Corrections: cfoil raffles orders update exposes the source reference and the note only. Changing
what was sold goes through a void, not an edit.
2.3.1. Draw numbers and voids
Numbers are issued consecutively and gaplessly per series from an in-transaction counter taken under a row lock — deliberately not auto-increment, which gaps on rollback (R10). Uniqueness is a database constraint, not application logic (R13).
cfoil raffles tickets inventory --raffle 7
cfoil raffles void-requests create --raffle 7 --ticket 918 --reason DATA_ENTRY_ERROR --reason-text "..."
cfoil raffles void-requests approve --raffle 7 --request 12 --note "Keyed twice; order 41 stands"
Every approval and rejection carries a note. The API refuses a resolution without one
(400 VALIDATION_ERROR naming note), so the record always says why the second person agreed or
refused, whichever client they used.
A requester can withdraw their own request by rejecting it, with a note
(cfoil raffles void-requests reject). Only approval needs a second person; withdrawing a mistake
does not. The same applies to refund requests. Both queues are also in the web interface (see
Section 4.4).
Void reasons: DATA_ENTRY_ERROR, DUPLICATE_ORDER, WRONG_PRICING_PLAN, PAYMENT_NOT_COMPLETED,
PURCHASER_REQUEST, REFUND, OTHER. REFUND is not selectable on an individual void — the only
way to reach it is the refund control, which makes "these voids were a refund" a reliable inference at
audit.
A void keeps its number forever and stays permanently visible in inventory flagged as void. The
inventory report reconciles exactly — unissued + issued + voided = configured total — for any
point-in-time query, and says so with an explicit reconciles assertion.
A pending void changes nothing; only an approved void removes a ticket from every pool. Pending
requests appear in inventory as voidPending so they are never invisible.
2.4. Phase 3 — Record the money
Counterfoil is record-only. Your merchant facility processes the payment; Counterfoil records what happened.
cfoil raffles payments record --raffle 7 --order 42 --method CHECK --amount 100.00 ...
cfoil raffles payments reverse --raffle 7 --payment 88 --reason NSF_RETURNED_CHECK --note "..."
cfoil raffles payments ledger --raffle 7 --order 42
cfoil raffles refunds request --raffle 7 --order 42 --reason PURCHASER_REQUEST --reason-text "..."
cfoil raffles refunds approve --raffle 7 --request 4 --note "Purchaser moved out of state"
As with voids, approving or rejecting a refund requires a --note.
Methods: CASH, CHECK, MONEY_ORDER, CREDIT_CARD_EXTERNAL.
The ledger is append-only and signed. Three entry types: PAYMENT (positive), REVERSAL and
REFUND (both negative). There is no update and no delete, so SUM(amount) for an order is its
amount paid — which makes "the report equals the sum of individual payment records" a definition
rather than a reconciliation exercise. There is no ADJUSTMENT type and adding one would break that
property.
Reversal and refund are different things and only one touches tickets.
-
A reversal (mis-key, returned cheque, chargeback) says the money never arrived or was never this much. The order drops out of paid status and its tickets stop being eligible — but they stay live, and the purchaser can still make good.
-
A refund returns money and must void every remaining ticket on the order in the same transaction. There is no state in which a refund exists against a live ticket. Each voided ticket gets its own void record stamped with the refund id, so an auditor sees one decision that voided N tickets rather than N unexplained voids.
Eligibility is all-or-nothing. An order’s tickets become drawing-eligible only when payments total the full order amount. Partial payment is not partial eligibility; it is no eligibility.
Two payment dates, never interchangeable. paymentDate is the business date the money moved — use
it for deposit matching and as the reconciliation report’s default axis. recordedAt is the clock
instant the system learned, and it is what pool determinism and audit replay use. A report’s basis
must apply to both sides of the report or its balancing identity means nothing.
No card number can exist anywhere. The last-4 field is four characters wide, and every
operator-supplied text field is scanned for any 13–19 digit run (separators between digits ignored). A
rejected value is never echoed back, logged, or written to the event log — you get
CARD_DATA_REJECTED and nothing else.
Overpayment is rejected (PAYMENT_EXCEEDS_ORDER_TOTAL). Money above the order total is not a
gambling receipt, and recording it here would corrupt the R18 breakout at its source.
Gross receipts are recognized when an order becomes fully paid, never prorated across installments. Partially paid money sits in an explicit held bucket until the order completes, which is what lets an order paid in installments across a period boundary balance in both periods.
cfoil raffles reconciliation --raffle 7 --from 2026-07-01 --to 2026-07-31
cfoil raffles financial-summary --raffle 7
A reconciliation report that does not balance is not returned with a warning flag — it fails, naming the discrepancy.
The financial summary’s netReceiptsProvisional is computed against configured prize exposure. It
is labelled provisional for a reason: never file on it. The tax worksheet uses prizes paid, and
the difference can be the entire grand prize.
2.5. Phase 4 — Receipts
Receipts are issued automatically for every fully paid order by a sweeper, so no operator has to remember. It runs on a schedule rather than inside the payment transaction deliberately — putting it there would hand the money path an outbound dependency it does not have.
cfoil raffles receipts list --raffle 7
cfoil raffles receipts document --raffle 7 --receipt 90 --out receipt.pdf
cfoil raffles receipts reissue --raffle 7 --receipt 90 --reason "Corrected address"
cfoil raffles print-batches generate --raffle 7
cfoil raffles print-batches document --raffle 7 --batch 3 --out mail.pdf
cfoil raffles print-batches confirm --raffle 7 --batch 3
A receipt carries every R9/R10 disclosure, resolved and frozen at issuance: organization name and licence number, raffle name, purchaser, order reference, every draw number, the plan option and amount paid, each drawing’s date/time/location, the winner-must-be-present flag, prize descriptions and any disclosed minimum.
Delivery channels are exactly EMAIL and MAIL_PRINT — the two R9 authorizes. There is no SMS
channel.
A receipt is a snapshot, so re-sending can never change what a purchaser was told, even after their address is corrected. A correction is a reissue: a new version supersedes the prior one, which is retained, and the draw numbers never change.
Printing is not confirmation. Generating the batch PDF marks nothing. A receipt becomes "printed for mail" only when an operator confirms the run. A jam is an explicit abandonment with a reason that requeues every member, and a run left unconfirmed past its expiry is abandoned automatically so a forgotten batch cannot strand receipts.
Email is at-least-once and bounces are polled, never received. Providers offer no idempotency key,
and R9 settles the choice between an occasional duplicate and an occasional silence. A hard bounce
queues a mail fallback and suppresses the address until it is corrected — but emailedAt stands,
because the send did happen. There is no inbound webhook anywhere in this system and adding one is out
of scope.
No card digits appear on a receipt: the payment method name only.
2.6. Phase 5 — Close the pool
A drawing’s eligible pool is a pure function of the locked configuration and an instant. Nobody decides membership by hand, and nothing in the pool engine can see a winner.
| Rule | Type | Membership |
|---|---|---|
|
|
Every paid, non-void ticket ordered on or before sales close |
|
|
The same, bounded by the drawing’s own cutoff. Winning removes nothing (R6) |
|
|
Every ticket of every purchaser holding at least the configured threshold by the cutoff. Bundle tickets count individually (R18) |
R6 is enforced by absence: no rule takes a winner as input, references a winner record or can even import the drawing code — asserted by a dependency test, not left to care. A scenario test proves today’s code does not subtract a winner; the dependency test proves the next engineer cannot make it.
cfoil pools list --raffle 7
cfoil pools show --raffle 7 --drawing 19 # rule narrative in plain English, counts
cfoil pools members --raffle 7 --drawing 19
cfoil pools close --raffle 7 --drawing 19 --note "Closed after the 3 Oct mail run was posted"
cfoil pools verify --raffle 7 --drawing 19
show prints the eligibility rule in plain English, generated from the locked configuration — that
is what you hand a WSGC inspector who asks how the pool was decided, and it cannot drift from the rule
because the rule generates it.
2.6.1. Closing
Closure is irreversible and happens exactly once per drawing. "Open" is the absence of a closure record and "closed" is its existence, under a uniqueness constraint — so irreversibility is the schema. There is no reopen, no re-close and no supersession, so there is no reopen path for an auditor to worry about.
cfoil pools close prints what it is about to freeze and then asks you to type the drawing’s name.
That prompt is reserved for this one command in the entire CLI, because it is the only irreversible
act a single operator can perform alone.
Every irreversible act records why. Closing a pool, cutting a counterfoil batch (in any mode),
recording a payment reversal, closing a drawing session and finalizing the tax worksheet each require
a written note or reason, as resolving a void, refund or destruction does. The API refuses the act
without one (400 VALIDATION_ERROR naming the field) and nothing changes.
Two guards stand in front of it:
-
POOL_CUTOFF_NOT_REACHED— you cannot close before the drawing’s effective entry cutoff. This is what makes irreversibility survivable: a pool closed before the sales it was meant to contain becomes impossible rather than merely regrettable. Do not campaign to relax it. -
POOL_PENDING_PAYMENTS— the cutoff bounds the sale; the closing instant bounds the payment. Choosing when to close decides which mail-in cheques made it, so you are told how many pre-cutoff orders remain unpaid and must acknowledge excluding them permanently. Closure is never scheduled, because that would make the exclusion silent.
2.6.2. After closing
Membership is frozen and can only shrink, never grow. No sale and no late payment joins a closed pool.
Membership and standing are separate questions. Membership is frozen; standing — can this stub still win — is computed live, so a void or a payment reversal takes effect immediately with no write anywhere (R13). Withdrawn members stay in the frozen list, named with a reason and an instant, because the stub is physically in the barrel and someone has to pull it.
cfoil pools verify recomputes the pool at its own closing instant and compares both the member set
and the hash to what was frozen. It never repairs. A mismatch means something immutable moved, and
rewriting the snapshot to agree would destroy the only evidence. A deterministic: null answer is not
corruption — it means the pool was closed under a rule version that has since been superseded and is
not reproducible by design.
2.7. Phase 6 — Cut and print the barrel
cfoil counterfoils generate --raffle 7 --drawing 19 --reason "Initial barrel for the grand drawing"
cfoil counterfoils manifest --raffle 7 --batch 31 --format pdf --out manifest.pdf
cfoil counterfoils preview --raffle 7 --batch 31 # alignment proof, 3 stubs max
cfoil counterfoils print --raffle 7 --batch 31 --printer tcp://192.168.1.50:9100
cfoil counterfoils runs --raffle 7 --batch 31
cfoil counterfoils verify --raffle 7 --drawing 19
A batch is a materialized, immutable set — not a query. Generation resolves the eligible pool at one instant and writes one row per stub, freezing the purchaser name, city, state and phone that print on it. Every one of those inputs stays mutable afterwards, so the record of what went into the barrel is frozen too. A batch is reproducible from its stored pool instant alone.
Each stub carries: draw number, purchaser name, city/state, phone, the raffle licence number, the
drawing identifier, batch id and sequence number, and a machine-readable code encoding
CF1:<raffleId>:<drawingId>:<drawNumber>.
Render profiles: ESCPOS_80 (the reference — a receipt printer with a partial cut between stubs,
which is exactly the tear-off artefact a barrel wants), ESCPOS_58 (carries a QR code instead of Code
128, which is not reliably scannable across a 384-dot head), PDF_LETTER and PDF_A4 (4-up with cut
guides). All four carry the identical payload.
Practical advice for a grand-prize barrel. Twenty thousand stubs on an 80 mm thermal roll is roughly
800 m of paper and 90 minutes of continuous printing. Use --render-profile PDF_LETTER and an office
printer with a guillotine: about fifteen minutes for the identical stub.
Supplements and replacements. A supplement takes the eligible pool minus every member of every active batch, which is what makes "the union of manifests equals the eligible pool" true by construction. Active batches for a drawing stay pairwise disjoint — a duplicate stub in the barrel is a second chance to win. A replacement supersedes every active batch, requires a reason and is Raffle-Administrator-only; superseded batches and all their stubs are retained forever.
Streaming bytes is not printing, and printing is not confirmation. cfoil counterfoils print opens
a run, streams the server’s bytes verbatim to the printer, and then asks whether they came out. Only
your confirmation records stubs as being in the barrel. A jam is abandon with a required reason; you
resume from where it stopped rather than reprinting. A transport failure never resolves the run — the
CLI exits 40 and says plainly that only a human can tell how much reached paper.
A confirmed run with no stream hash is meaningful, not missing data: it marks a range accepted off a partial stack rather than driven through a device in that run. Do not backfill it.
The CLI does no layout and no encoding — the server renders, because the server is the signed, lab-certifiable artefact and rendering a $1M drawing’s printed evidence in an unsigned client would put it outside the certified boundary.
Verification compares the manifest to the eligible pool and produces the numbers you act on:
| Finding | What to do |
|---|---|
|
Tickets sold since you printed. Generate a supplement batch |
|
A stub in the barrel that has lost standing — a void, a reversed payment. Pull it by hand. Counterfoil names the draw numbers |
print coverage < full |
Confirmed runs do not yet cover the batch. Finish or resume the runs |
2.8. Phase 7 — Prize evidence and the reserve (do this before drawing night)
This is the phase most likely to stop a drawing at the worst possible moment. Start it weeks out.
cfoil prize-records missing --raffle 7
cfoil prize-records add --raffle 7 --drawing-prize 44 --type CASH --value 1000000.00 \
--doc-type DEPOSIT_EVIDENCE --doc-ref "First Washington ref 88213" --doc-date 2026-11-02
cfoil prize-records add --raffle 7 --drawing-prize 45 --type NONCASH --value 62000.00 \
--doc-type PURCHASE_CONTRACT --doc-ref "Contract 2026-118" --supplier "Eastside Motors"
cfoil reserve record --raffle 7 --amount 1250000.00 --as-of 2026-12-01 \
--source "First Washington statement 2026-12-01"
cfoil reserve check --raffle 7 --drawing 19
2.8.1. Prize records
Your locked configuration records a prize’s disclosure. It deliberately does not know whether
satisfying that prize takes money out of the bank or a car off a lot — and R11 needs exactly that.
prizeType is CASH or NONCASH, and it lives here, not in the configuration. Never ask for a
cash/noncash column on the configured prize: that would reopen locked configuration for a fact that is
not a disclosure.
Without a prize record the reserve obligation cannot be stated at all, which is why a missing record blocks a drawing session and is not overridable.
A prize record may document a prize but never restate it: the retail value is validated to equal
the locked disclosure, and a divergence is PRIZE_VALUE_MISMATCH — not a number to reconcile later.
Evidence lives outside the pre-sale lock, because a dealership signs in November for a December
drawing and requiring the paper before the first ticket would make the lock unsatisfiable. It then
acquires a lock of its own: the moment a drawing session opens against it, the record freezes
(PRIZE_RECORD_LOCKED), because that session’s gate and its frozen reserve position were computed
from it.
Document types: PURCHASE_CONTRACT, AVAILABILITY_EVIDENCE, DEPOSIT_EVIDENCE, OTHER. A
NONCASH prize above the configured documentation threshold (default $500) needs a document reference
before a session can open — R11’s second half.
2.8.2. Reserve balances
An append-only log of observations, with no update path, no delete path and — unlike payments — no reversal type. That asymmetry is deliberate: a payment accumulates, so a correction must subtract and the sum is the truth; a balance is an observation of a level, so a correction supersedes and the latest observation as of a date is the truth. Summing the column would be meaningless.
Two dates as always: asOfDate is what the statement speaks to, recordedAt is what the clock says.
2.8.3. The check
The obligation spans the drawing’s local calendar date, not the drawing. Two drawings the same evening draw on one bank balance, so every active drawing sharing that date in the raffle’s configured time zone is summed. Prizes already awarded are not excluded — the conservative reading is the whole day’s obligation, and excluding them would let a shortfall pass on a redraw.
The answer is one of three, and conflating the middle two would be a serious compliance error:
| Status | Meaning |
|---|---|
|
The check ran; recorded unencumbered balance ≥ the day’s cash prize obligation |
|
The check ran; the funds are short |
|
The check could not run: no balance on file, a balance older than 7 days, a prize with no record, or a percentage-of-receipts prize this version has no valuation rule for |
Reporting a non-answer as FAILED writes a false claim about the licensee’s bank account into a
permanent R17 artefact. Reporting it as PASSED is worse. The freshness window is a code constant,
not configuration, precisely because whether a WAC check can be considered performed is a compliance
question and not a deployment preference.
2.9. Phase 8 — Drawing night
2.9.1. Runbook
T-minus one week
-
cfoil pools verify --raffle 7 --drawing 19— deterministic. -
cfoil prize-records missing --raffle 7— empty. -
cfoil reserve check --raffle 7 --drawing 19—PASSED, or you know exactly why not.
T-minus one day
-
cfoil counterfoils verify --raffle 7 --drawing 19— coverage complete,inPoolNotPrintedempty. -
Record a fresh reserve balance if the last observation is approaching 7 days old — a stale one answers
NOT_AVAILABLE, notPASSED. -
cfoil drawings readiness --raffle 7 --drawing 19— read every blocker.
On the night
cfoil drawings readiness --raffle 7 --drawing 19
cfoil drawings open --raffle 7 --drawing 19 --ack 000412 --ack 000998
cfoil drawings draw --raffle 7 --session 5
cfoil drawings award --raffle 7 --session 5 --event 12 --prize 44 --witness sam@org.example
cfoil drawings disqualify --raffle 7 --session 5 --event 13 --reason NOT_PRESENT --text "..."
cfoil drawings close --raffle 7 --session 5 --closing-note "All prizes awarded and witnessed"
cfoil drawings report --raffle 7 --session 5 --format pdf --out drawing-19.pdf
2.9.2. The gate
readiness and open run the same pure gate, so a preview and the act can never disagree. A
session opens only when:
| Blocker | Overridable? |
|---|---|
|
No |
|
No |
|
No |
|
No |
|
No |
|
No |
|
Yes, with a recorded reason |
Every non-overridable blocker means the barrel does not match the record, and each has a thirty-second remedy in this database. The reserve is the only one that is a fact about the outside world, which is why it is the only one you can override — and the override reason is stored on the session and printed in the report.
Attesting withdrawn stubs. The system cannot see a barrel, so it asks. The attested set must equal the computed withdrawn-and-printed set: there is no blanket acknowledgement, an extra draw number is refused by name and a missing one blocks by name. Pull the physical stubs first, then attest them. The list is frozen onto the session and printed in the report.
Opening freezes the reserve position, the manifest totals and the pool counts onto the session. Nothing recomputes a closed session — a session closed last year keeps the snapshot it froze.
2.9.3. Drawing
cfoil drawings draw is a plain read-a-line loop, because a barcode scanner is a keyboard. It has no
completion, no history recall and no "draw next" affordance anywhere. Entry method is SCAN or
KEYED, and what you scanned is stored verbatim — the system never normalizes it, because a client
editing what was scanned is a client editing evidence.
A rejected stub is a record, not an error message. Presenting a stub always returns 201 with the
outcome in the body; a 4xx would put the evidence of an attempted bad draw into an application log
instead of the record. Rejection reasons: MALFORMED, WRONG_RAFFLE, WRONG_DRAWING, NOT_IN_POOL,
WITHDRAWN, ALREADY_DRAWN. Each is written into the sequence with its position, so the report shows
every refusal in the order it happened.
Outcomes on a draw event: PRESENTED → WINNER, DISQUALIFIED, or REJECTED.
2.9.4. Awarding
Two people at one keyboard, as described in §1.4. The witness must differ from you and must hold
RAFFLE_ADMINISTRATOR or FINANCE_RECORDS_OFFICER.
Every failure answers WITNESS_INVALID.
Disqualification reasons: NOT_PRESENT, INELIGIBLE_PURCHASER, DECLINED, OTHER, each with
required free text. The full ordered history — disqualifications and redraws included — is the report,
not just the final winner.
2.9.5. Closing
The Raffle Drawing Report is generated atomically with the close and can never be rebuilt. There is no closed session without a report, no report without a closed session, and no regenerate endpoint. It carries the drawing’s identity, date/time/location, pool size, the counterfoil batches used and their checksums, every draw event in sequence, the winners and prizes, the officiating users, the attested withdrawn stubs and the frozen reserve position (R17).
Two fetches years apart return identical bytes.
Abandoning a session instead requires a reason and is refused if any winner has been recorded
(SESSION_HAS_WINNERS).
2.10. Phase 9 — Fulfil the winners
cfoil winners list --raffle 7 --status PENDING
cfoil winners fulfil --raffle 7 --fulfilment 9 --delivered-on 2026-12-15 \
--acknowledgment "Signed receipt 2026-12-15"
cfoil winners forfeit --raffle 7 --fulfilment 9 --reason "Not present at the drawing"
cfoil winners register --raffle 7 --format pdf --out winners.pdf
Every winner gets a fulfilment record inside the award transaction: no winner without one, and no
record without a winner. States are PENDING → AWARDED | FORFEITED_REDRAWN, both terminal, each
transition carrying its user, instant and reason.
Forfeiture is the interlock that makes a redraw legal, and the order is not negotiable. A generated database column nulls itself on forfeiture under a unique index, so at most one unforfeited winner exists per prize across sessions. Forfeit first, then redraw — attempt it the other way and the award simply fails on the key.
deliveredOn is not paperwork: it is what finally resolves R12’s "printed tickets kept ≥ 30 days or
until the prize is awarded", and without it the retention answer for the physical counterfoils is
NOT_COMPUTABLE.
There is no returned-prize state, no procurement workflow, and no tax computation or withholding of any kind — federal tax reporting is L5, and the winner record reserves an attributes area that nothing currently reads.
2.11. Phase 10 — Close the books
See Chapter 3, which is entirely about this phase.
3. Compliance reporting for regulators
3.1. What each artefact is worth, and to whom
| Artefact | Command | Audience | Guarantee |
|---|---|---|---|
WSGC standard record set |
|
WSGC records inspection (R12) |
Content hash; blocked if it does not reconcile |
Local gambling-tax worksheet |
|
Your city/county (R19) |
Byte-identical once finalized |
Raffle Drawing Report |
|
WSGC, GLI lab (R17) |
Byte-identical, immutable, one per closed session |
Prize-and-winner register |
|
WSGC winners recordkeeping (R12) |
Content hash over the figures alone |
Counterfoil manifest |
|
WSGC, physical-pool completeness (R12/R13) |
Byte-identical; checksum over ordered members |
Ticket inventory |
|
WSGC unsold/void listing (R12) |
Reconciles at any point in time |
Reconciliation & financial summary |
|
Internal + WSGC (R18) |
Fails rather than returns unbalanced |
Significant-event interrogation |
|
GLI lab (R14, §5.6) |
Append-only, hash-chained |
Encrypted raffle archive |
|
Your records custodian (R12) |
Offline-verifiable, manifest of checksums |
Destruction certificate |
|
WSGC (R12) |
Permanent, survives the destruction it records |
Every report carries its generation timestamp, generating user, raffle licence number and content
hash, and every fetch of one emits an EXPORT_PRODUCED event.
3.2. The record set, and the ten identities behind it
cfoil records reconcile --raffle 7
cfoil records set --raffle 7 --format pdf --out record-set.pdf
The record set is gross receipts with the R18 discounted-sales breakout, prizes paid, net receipts, the ticket-sales summary, the winners register and the unsold/void ticket listing.
Counterfoil composes it and never recalculates it. Every figure is fetched from the part of the
system that owns it — gross receipts from the payment ledger, ticket counts from the inventory, pools
from the eligibility engine, batches from the barrel, results from the drawing reports, winners from
fulfilment. The only arithmetic performed here is net receipts and the tax. A second SUM over
payments in the reporting code would be a second definition of gross gambling receipts, which is
exactly the mistake the barrel was forbidden from making with pool membership.
A mismatch blocks generation (RECORD_SET_NOT_RECONCILED) and names the discrepancy.
cfoil records reconcile shows the verdicts whether or not they block:
| Identity | What it proves |
|---|---|
|
The per-plan R18 breakout sums to total gross receipts |
|
Gross − prizes paid = net |
|
The order-level paid cache still equals the ledger. The most valuable check in the product — drift here would misstate gross receipts in a way nothing else would catch |
|
Paid ticket counts agree with the ledger and never exceed issuance |
|
Unissued + issued + voided = the configured total, per series and overall |
|
Every drawn pool is closed, and still recomputes to what was frozen |
|
Active batches are disjoint and every manifest carries its checksum |
|
Every closed session has exactly one report |
|
The winners in the reports are the winners in the register |
|
Every prize record still equals its locked disclosure |
3.3. The local gambling-tax worksheet (R19)
cfoil tax preview --raffle 7 # live, marked finalized:false
cfoil tax finalize --raffle 7 --note "Filed with the Spokane County Treasurer"
cfoil tax show --raffle 7 --format pdf --out tax-worksheet.pdf
Finalizing requires a note. It records why the filing was frozen when it was, and it is stored
beside the filing, not inside it: it is not one of the filed figures, so it is not part of the
worksheet’s content hash, its PDF or its report exports, and a worksheet finalized earlier reads
exactly as it did. cfoil tax show and the API return it as finalizationNote; the archive carries
it; the finalization event records it.
Tax = (net receipts − the $10,000 exemption, floored at zero) × the raffle’s effective rate, capped at 5%. The document shows every input, the jurisdiction (city/county/state), whether the rate was system-suggested or administrator-overridden, the citation and the rate’s effective date.
The worksheet uses prizes paid, never prizes configured. Prizes paid is the retail value of every prize held by an unforfeited winner. Prizes configured but never awarded contribute nothing and are printed as their own line, because they raise the taxable figure and their absence must be visible rather than inferred.
Finalization freezes the worksheet and the tax rate together, in one transaction, and is a
significant event. Afterwards cfoil raffles tax-rate refuses with TAX_RATE_FINALIZED. Regenerating
from unchanged data yields an identical document.
Preconditions you will meet: RAFFLE_NOT_COMPLETE, RECORD_SET_NOT_RECONCILED,
CUSTODIAN_NOT_CONFIGURED, and EXEMPTION_ALREADY_CLAIMED.
On the exemption. RCW 9.46.110 exempts the first $10,000 per organization per year, not per raffle. For a licensee running one enhanced raffle a year — this product’s target — the two readings coincide. Where they diverge, finalization refuses unless you acknowledge it explicitly. Counterfoil does not apportion the exemption across raffles: that is your filing decision with your compliance lead, not a rule to invent in software.
3.4. The report catalogue
cfoil records catalog --raffle 7
cfoil records report --raffle 7 --key reconciliation --from 2026-01-01 --to 2026-12-31 \
--format pdf --out reconciliation.pdf
One surface, one cover page carrying the WAC 230-11-105 custodian details, one export event and one
hash discipline. Keys: record-set, tax-worksheet, reconciliation (needs from/to),
financial-summary, ticket-inventory. The module-specific endpoints still work unchanged; this adds
a uniform way to fetch any of them, and brings reconciliation, the financial summary and the ticket
inventory into PDF.
3.5. Answering a WSGC inspector
| The question | The answer |
|---|---|
"Show me the records for this raffle." |
|
"How was the eligible pool for the early bird decided?" |
|
"Prove the barrel was complete." |
|
"Who drew the winner, and who witnessed it?" |
The Raffle Drawing Report; both officers are named, and the database refused to let them be the same person |
"Show me every redraw." |
The same report — the full ordered sequence, disqualifications included |
"Were the funds on deposit at the time of the drawing?" |
The reserve position frozen onto the session, printed in the report, with the source document named |
"What happened to draw number 004182?" |
|
"Who voided these and who approved it?" |
The void records; requester and approver are separate people by construction |
"Show me the unsold tickets." |
|
"Where are your records kept?" |
|
3.6. Evidence for a GLI lab (GLI-31 v1.1)
-
§5.6 interrogation —
cfoil events searchfilters by any combination of date/time range, event type, user, entity type and entity id. -
Chain integrity —
cfoil events verifyre-links the whole chain; a break raisesEVENT_CHAIN_BROKEN. Verification also runs nightly and, by default, at startup. -
Reproducibility — regenerate any frozen artefact and diff the bytes.
-
R2 — the source scan banning every randomness primitive from the counterfoil, pool and drawing packages, and the non-nullable columns that make a machine-generated draw unstorable.
-
R6 — the package-dependency test proving the pool engine cannot import the drawing code.
-
Two-person controls — the database constraints, not just the service checks.
-
Determinism — the nightly pool recomputation and its published verdict.
Items Module 13 still owes a lab: signed builds with startup software verification, the compliance matrix structured to GLI-31 v1.1 section numbering, backup/restore evidence, row-level integrity checks and clock-drift alerting.
3.7. The encrypted archive (R12 records custody)
cfoil archive export --raffle 7 --passphrase-stdin < passphrase.txt
cfoil archive status --raffle 7
cfoil archive download --raffle 7 --export 3 --out raffle-7.cfar
cfoil archive verify --file raffle-7.cfar --passphrase-stdin < passphrase.txt
cfoil archive extract --file raffle-7.cfar --passphrase-stdin < passphrase.txt --out ./records
The archive holds every record class for one raffle: orders, purchasers, payments, tickets, pools, counterfoil manifests, drawing reports, winners, generated reports and the raffle’s significant events, plus a manifest of file-level checksums and record counts.
verify and extract run with no server, no database and no network. That is a PRD requirement,
not a convenience: the utility and the server share one implementation of the container format,
because two implementations of a cipher is how a format silently forks.
The chain witness is what makes a per-raffle cut of a global hash chain verifiable. Exporting only
one raffle’s events would leave holes indistinguishable from deletions, so the archive carries
(seq, eventHash, prevHash) for every event in the covered range — hashes only, no payloads. An
offline verifier re-links the excerpt into an unbroken chain without the archive containing one byte
about another raffle.
Round-trip check: cfoil archive status --export 3 returns the live record counts the archive’s
manifest is compared against. That comparison never requires the key to come back to the server.
The passphrase is never stored, hashed, hinted at or returned — not even as a derived verifier, which would be an offline-crackable artefact of a key we promised not to keep. There is no escrow, no recovery and no reset. This is also why there is no queued export state: the key lives only in the requesting process, so a restart mid-export fails it unresumably, with that reason recorded. Escrow the passphrase in your organization’s own key custody arrangement before you export.
3.8. Retention and destruction (R12)
cfoil retention --raffle 7
cfoil destruction request --raffle 7 --class TICKET_STUB_DETAIL --reason "Retention expired"
cfoil destruction approve --raffle 7 --request 2 --approve --note "Archive 5 verified offline"
cfoil destruction cert --raffle 7 --certificate 1
A destruction decision is stated, never assumed. The approval requires both an explicit
approve (true or false; in cfoil, --approve or --reject) and a note. A request that omits
either is refused and nothing is redacted.
| Record class | Clock |
|---|---|
|
3 years from the fiscal-year end in |
|
1 year |
|
30 days from the drawing or until the prize is awarded — advisory, since this system holds no paper |
NOT_COMPUTABLE is a real answer, not a failure: either no fiscal-year end is configured, or a
winner has not claimed so the "or until the prize is awarded" half is genuinely unresolved. A guessed
date on a compliance screen would be worse than an honest gap.
Destruction is redaction under a permanent certificate, never a delete. Every count, figure, draw number and hash survives; only personal detail is overwritten — which is why the record set still reconciles the day after one runs, and why the certificate itself is retained forever.
Three hard preconditions:
-
Retention must have expired. An attempt inside the window is refused and logged — somebody tried to destroy records early, and that is exactly the kind of thing an interrogation log exists to surface.
-
A completed archive must exist (
ARCHIVE_REQUIRED). The obligation is to maintain records in Washington; destroying the live copy with no archive on file is data loss with a form attached. -
A different person must approve, and every precondition is re-checked at approval rather than trusted from request time.
Frozen artefacts are never destructible: receipts, drawing reports, counterfoil manifests, tax worksheets and the event log. WAC requires their retention and nowhere requires their destruction — so accept that a winner’s name survives in an immutable R17 report after a purchaser redaction.
4. Routine operations
4.1. What runs on its own
| Job | Default schedule | What it does |
|---|---|---|
Receipt auto-issue |
every 30s |
Issues a receipt for every newly fully-paid order |
Email dispatch |
every 30s |
Sends queued receipts with no transaction open |
Bounce poll |
hourly at :07 |
Queues mail fallback for hard bounces |
Receipt print-batch expiry |
every 15m |
Abandons forgotten batches, requeuing their members |
Counterfoil run expiry |
every 15m |
Abandons forgotten print runs |
Event chain verification |
02:00 UTC, and at startup |
Re-links the whole chain |
Pool integrity sweep |
03:15 |
Recomputes every closed pool and compares |
Drawing integrity sweep |
03:25 |
Report hashes, sequence gaplessness, no winner outside its pool |
Prize integrity sweep |
03:40 |
Every winner has a fulfilment record; no prize held twice |
Records integrity sweep |
03:50 |
Worksheet immutability, archive presence, certificate provenance |
No sweep ever repairs. A finding is an alarm; a silent repair would destroy the only evidence that something moved.
4.2. A sensible operator rhythm
-
Daily: the unpaid work queue (
cfoil raffles orders list --payment-status UNPAID|PARTIAL), receipt delivery failures, and any sweep findings from last night. -
Weekly:
cfoil raffles reconciliationagainst the bank,cfoil raffles tickets inventoryreconciles,cfoil events verify. -
Before each drawing: the Phase 8 runbook.
-
After the last drawing: record every fulfilment, then
cfoil records reconcileuntil clean, then finalize the tax worksheet, then export the archive.
4.3. Reading the logs
Counterfoil writes an operational log to standard output, separate from the significant-event log. They answer different questions and neither replaces the other: the event log is the tamper-evident legal record an auditor interrogates months later; this is what tells you, now, what the system is doing.
4.3.1. Levels
| Level | What it means | What to do |
|---|---|---|
|
The system failed at something it is responsible for. |
Act now. Integrity-sweep findings, a broken event chain and archive-export failures are all here. |
|
The system behaved correctly and refused, retried or degraded something. |
Read it in the morning; investigate if it repeats. Refused orders, rejected card data, lockouts, bounced receipts, a failed reserve check. |
|
A record-bearing state transition, or a lifecycle fact. |
Nothing. This is the narrative: orders completed, payments recorded, pools closed, winners recorded, reports generated. |
|
The decision behind a transition — inputs, intermediate values, the branch taken. |
Off in production. Turn on for one area when reproducing a specific problem. |
|
Unused. |
Nothing; enabling it is guaranteed quiet. |
4.3.2. Finding one request
Every line carries the request it belongs to. In the default human-readable format the prefix is
[counterfoil,<requestId>,<userId>,<raffleId>,<job>]; under COUNTERFOIL_LOG_FORMAT=ecs each is a
top-level JSON field. Background work carries job instead of requestId, so a scheduled line and a
request line are never confused.
The requestId is returned to the caller as the X-Request-Id response header, which is what makes
"it failed at about ten past nine" into a single exact search.
4.3.3. Turning up the detail
There is deliberately no endpoint that changes a log level on a running server. A level is deployment configuration, like the port and the database URL, so raising one is an environment change and a restart:
COUNTERFOIL_LOG_LEVEL_POOL=DEBUG # then restart
The per-area variables are COUNTERFOIL_LOG_LEVEL_{ORDER,PAYMENT,POOL,DRAW,RECEIPT,RECORDS}, plus
COUNTERFOIL_LOG_LEVEL for everything at once. Each is empty by default and inherits.
The practical consequence: you cannot raise a level in the middle of a drawing. If you expect to
need the detail, start that evening’s session with the area already at DEBUG.
|
Warning
|
Never set |
4.3.4. What never appears in the log
By design, the operational log contains no purchaser or user names, no email addresses (masked or otherwise), no addresses, no phone numbers, no card digits, no session tokens and no archive passphrases. People and records are named by their id. To go from an id to a person you need database access, which is authority the log itself does not confer.
The one exception is the client IP address, which appears on authentication lines only — a lockout you cannot attribute to a source is not something you can act on.
4.4. The administration web interface
The server also serves a browser interface for account administration, raffle configuration and a Raffle Administrator’s approvals and oversight, at
https://<host>:9143/ui/
on the same port and from the same jar as the REST API. It is a client of that API, not a second
implementation of it: every form calls the API’s own code in-process, so a change made in the browser
writes the same rows, is attributed to the same user, emits the same significant events and is
refused with the same error code, rule and citation as the equivalent cfoil command. It adds no
endpoint to /api/v1 (see Appendix B).
Sign in with the same account and password as cfoil. Lockout, the per-address rate limit and the
idle and absolute session timeouts are shared: five failed sign-ins in any mix of browser and cfoil
lock the account. A user whose password must be changed is held on the password page until they
change it. A session that expires with a form open sends the user to the sign-in page and discards
what was typed; nothing is saved.
4.4.1. Supported browsers
Current stable desktop Google Chrome and Mozilla Firefox. Pages are plain forms: every page is a full load, no request is made after a page has loaded, and nothing is fetched from another origin, which the server’s Content-Security-Policy enforces in the browser. With scripts disabled every function still works; times are then shown in UTC (still labelled) and the drawing form shows every input.
4.4.2. What it does
What appears on the home page and in the navigation depends on the signed-in user’s roles. A user who is neither a System Administrator nor a Raffle Administrator sees a notice that no other functions are available to them in the browser yet.
| Area | Role | Tasks, with the cfoil equivalent |
|---|---|---|
Users |
System Administrator |
List and filter ( |
Sales units |
System Administrator |
List, register a workstation, retire one ( |
Tax jurisdictions |
System Administrator |
List, add a rate row, correct a row not yet in effect ( |
Event chain |
System Administrator |
Run a verification and read the latest result ( |
Destruction approvals |
System Administrator, Raffle Administrator |
Approve or reject a pending destruction request with a required note ( |
Event log |
System Administrator, Raffle Administrator |
Search the significant-event log by time, event type, actor and entity, paged ( |
Raffles |
Raffle Administrator |
List and open raffles with their compliance checklist ( |
Void approvals |
Raffle Administrator |
A locked raffle’s void-request queue, pending by default: approve, reject or withdraw, each with a
required note ( |
Refund approvals |
Raffle Administrator |
A locked raffle’s refund-request queue: approve with an optional payment date, reject or withdraw,
each with a required note ( |
Ticket inventory |
Raffle Administrator |
A locked raffle’s reconciling inventory per series and in total, the ticket list filtered by status,
series and pending void, and a lookup by draw number ( |
Every removal, disable, retirement and destruction decision asks for confirmation on a page of its own before anything is written. Removing a pricing plan, drawing or prize is a soft removal, exactly as through the API; Show removed on the raffle page lists removed rows. A locked raffle’s page offers no add, edit or remove control at all; only its tax rate stays editable.
4.4.3. Approvals in the browser
The void and refund queues carry out the same two-person controls as cfoil (see Section 2.3.1
and Section 2.4). Every decision — approve, reject or withdraw — is made on a
confirmation page of its own and requires a note saying why; nothing is written until it is
submitted. The confirmation shows what the decision will do: for a void, the draw number, the
ticket’s current status, the reason and who asked; for a refund, the order’s total, what is paid now,
the requested amount and every draw number that will be voided.
Approve is never offered on your own request. The person who asked cannot be the person who agrees, so on a request you made yourself the only action is Reject, labelled as a withdrawal — the way to take back a request made by mistake without waiting for a colleague. A request that is no longer pending shows no action at all.
A refund whose order has moved is refused, not adjusted. If a payment has been recorded or reversed
since the refund was requested, the confirmation says so before you approve, and the server refuses
the approval (REFUND_NOT_ELIGIBLE) and writes nothing; the requester opens a new request at the
current amount. A refund refused because a ticket on the order has a pending void points to the void
queue: resolve the void first.
People are shown as Full Name <email>, read from the user’s current account when the page loads,
so a renamed user’s history reads under the new name. Purchaser details are not shown on these pages.
4.4.4. Searching the event log
The Event log searches the append-only significant-event log (R14). Every filter is part of the page’s address, so a search can be bookmarked or sent to a colleague, and every page of results keeps the filters. Results are newest first by sequence number unless Time is chosen. Each row shows the event’s sequence number, type, time, actor, entity, its masked summary and the first twelve characters of its hash and of the previous event’s hash; the full hash is shown on hover.
Times are entered in the browser’s zone, the same zone the results are shown in, so a time read off a result can be typed back in. Each bound is labelled with that zone. From is included and To is not; a bound needs both a date and a time, and a date alone is refused rather than read as midnight. A local time inside a daylight-saving change is refused exactly as on a raffle form. With scripts disabled the browser reports no zone: the bounds are then read as UTC and the page says so.
Sign-in events never name the person. LOGIN_SUCCESS, LOGIN_FAILURE and ACCOUNT_LOCKOUT show the
actor as user <id> with the note Identity withheld on sign-in events; every other event shows the
actor’s current Full Name <email>, or system for an event no user caused. Searching writes
nothing to the log.
4.4.5. Dates and times in the browser
A raffle’s date-times — sales open and close, and each drawing’s scheduled time and entry cutoff —
are entered in the raffle’s own time zone, whatever zone the browser is in. Each input is labelled
with that zone, and the server converts it to the instant it stores. A local time that does not
exist because clocks jump forward (02:30 on a spring-forward date) or that happens twice because
they fall back (01:30 on a fall-back date) is refused with the field named and every other value
kept; pick a time outside the change. The minutes either side, such as 01:59 and 03:00, are
accepted.
Read-only times elsewhere are shown in the browser’s own zone with a zone abbreviation, such as
2026-11-30 00:00 PST. The raffle page also shows each configuration time’s reading in the raffle’s
zone. Calendar dates, such as a tax row’s effective date, are dates and are never converted.
Changing a draft raffle’s time zone
Changing a draft raffle’s time zone never moves a stored moment; only its local reading moves. When
the zone is changed on the edit form, nothing is saved until a confirmation page has listed every
affected time — the two sales times and each active drawing’s scheduled time and entry cutoff — with
its reading in the old zone, its reading in the new zone and the unchanged instant. For a raffle in
America/Los_Angeles moved to America/Denver:
| Field | Now reads (Los Angeles) | Will read (Denver) | Stored instant (unchanged) |
|---|---|---|---|
Sales close |
|
|
|
Confirm saves the raffle with the new zone and every instant as it was; Cancel saves nothing. To keep a field’s original wall-clock time in the new zone, edit that field afterward — nothing is re-saved automatically.
If someone changes a raffle’s zone while another user has one of its forms open, that user’s save writes nothing: the form comes back with each time they entered shown in the new zone, and a notice asking them to check each time and save again.
4.4.6. Serving it over HTTPS
The interface refuses any request the server believes is plaintext (R15). The repository ships no TLS configuration, so a deployment uses one of two shapes:
-
Tomcat terminates TLS, configured with
server.ssl.bundle(orserver.ssl.*) in/var/lib/counterfoil/config/application.properties. A plain-HTTP request to port 9143 then fails the TLS handshake. -
A reverse proxy terminates TLS (nginx, Apache) and forwards plain HTTP to port 9143. The server reads
X-Forwarded-ProtoandX-Forwarded-Forby default (COUNTERFOIL_FORWARD_HEADERS_STRATEGYisnative), so the proxy must set both, for example in nginx:proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
The headers are trusted only when the proxy connects from loopback or a private address (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16); from anywhere else they are ignored and the request is treated as the plaintext it is. If browsers can also reach port 9143 directly from one of those private ranges, bind the server to loopback or firewall the port, or narrowserver.tomcat.remoteip.internal-proxiesto the proxy’s own address — otherwise such a client can claim any address and step outside the per-address sign-in rate limit. Without the headers, every browser request is refused and every sign-in appears to come from the proxy’s address, so all users share one rate-limit bucket.
COUNTERFOIL_UI_REQUIRE_HTTPS=false lets the interface run over http://localhost for development
and logs a warning at startup. It must never be set on an instance holding real data.
4.4.7. Switching it off
COUNTERFOIL_UI_ENABLED=false and a restart remove the interface entirely; /ui/ then answers
404. The REST API, cfoil and every session are unaffected, and no data is involved, so this is
the rollback lever if the interface misbehaves. The startup capability report states which way it is
set (the WEB UI line).
5. Configuration reference
Every counterfoil.* key in src/main/resources/application.yml, alphabetically. Values shown are
the shipped defaults; ${VAR:default} means the key reads that environment variable.
Anything not in this table is not configurable by design. In particular the regulatory limits (the $250 ticket cap, the $10,000,000 grand-prize cap, the 5% tax cap, the $10,000 exemption), the Washington residency rule, the reserve-balance freshness window and the Argon2id cost behind the archive key are code constants, so a deployment cannot weaken a compliance rule from a config file.
| Key | Default | What it controls |
|---|---|---|
|
|
Password hash length in bytes. |
|
|
Argon2id time cost for password hashing. |
|
|
Argon2id memory cost in KiB (64 MiB). |
|
|
Argon2id lanes. |
|
|
Password salt length in bytes. |
|
|
Sign-in attempts allowed per source address per window. Also applies to in-band witness verification. |
|
|
The rate-limit window. |
|
|
Consecutive failures before an account locks. Fires at exactly this count. |
|
|
First-boot System Administrator seed. Delete after first boot. |
|
|
Runaway guard on one batch. Never a business cap — a batch must equal the eligible pool. |
|
|
Partial cut every N stubs. Raise for perforated stock. |
|
|
|
|
|
How often forgotten print runs are auto-abandoned. |
|
|
Stubs in an alignment preview — never a way to print a barrel unrecorded. |
|
|
How long an unresolved print run stays open. |
|
|
Draw numbers named in a verification response before truncation. |
|
|
Maximum page size on the draw-event sequence. |
|
|
Nightly drawing integrity sweep. |
|
|
Whether that sweep runs. |
|
|
Minimum characters in an R11 reserve override reason. |
|
|
Interrogation search page cap. |
|
|
Nightly chain verification (02:00 UTC). |
|
|
Events verified inline before the job switches strategy. |
|
|
Verify the chain at boot. |
|
|
Pricing-plan lines allowed on one order. |
|
|
Draw numbers one order may mint. |
|
|
The licensee profile. Absent ⇒ no raffle can issue a ticket. Permanent. |
|
|
v1-minimal policy. Complexity and breached-password checks are a pre-certification gate. |
|
|
How far back a payment’s business date may be set. |
|
|
Nightly pool determinism sweep. |
|
|
Whether that sweep runs. |
|
|
Pool-member page cap. |
|
|
Withdrawn members named in a response before truncation. |
|
|
Nightly prize/fulfilment sweep. |
|
|
Whether that sweep runs. |
|
|
How far back a reserve observation’s |
|
|
Retail value above which a |
|
|
Winner-register page cap. |
|
|
Default zone for a new raffle. Decides the local calendar date the R11 reserve obligation spans. |
|
|
Issue receipts automatically for fully paid orders. |
|
|
How often the auto-issue sweeper runs. |
|
|
Attach the PDF so both channels deliver an identical document. |
|
|
Retry backoff, doubling per attempt. |
|
|
Provider endpoint. Must be |
|
|
Deliveries claimed per dispatch pass. |
|
|
How far back each bounce poll looks. |
|
|
Bounce poll schedule. Bounces are polled, never received — there is no inbound webhook. |
|
|
How long a claimed delivery stays claimed before it is reclaimable. |
|
|
Provider connect timeout. |
|
|
How often the outbox dispatcher runs. |
|
|
Off by default. Disabled, every receipt queues for mail, which is equally lawful. |
|
|
Sender address; needs a verified sending domain. |
|
|
Sender display name. |
|
|
Delivery attempts before the record is failed. |
|
|
Provider message stream. |
|
|
|
|
|
Provider read timeout. |
|
|
Environment only; never write it into this file. |
|
|
Receipts issued per auto-issue pass. Bounds the drain when auto-issue is first enabled on a live raffle. |
|
|
How long an unconfirmed mail batch stays open. |
|
|
How often forgotten mail batches are abandoned. |
|
|
Receipts per mail print batch. |
|
|
|
|
|
Position the address block for a #10 double-window envelope. |
|
|
Where completed |
|
|
AES-GCM frame size inside the container. |
|
|
An export exceeding this is failed. |
|
|
Operational floor on operator input. The Argon2id cost behind it is a code constant, so a deployment cannot weaken key derivation itself. |
|
|
How long completed archives stay on disk. Safe at rest by construction — the server does not hold the key. |
|
|
The WAC 230-11-105 custodian, records location and fiscal-year end. Absent ⇒ no filing, archiving or
destroying; sales unaffected. Permanent. |
|
|
Nightly records integrity sweep. |
|
|
Whether that sweep runs. |
|
|
Page size for catalogue reports. |
|
|
Page cap on a rendered record set. |
|
|
Hard session lifetime regardless of activity. |
|
|
Inactivity before a session expires. |
One more dev.cfoil value, outside the counterfoil. tree:* logging.level.dev.cfoil (default
INFO) sets the application log level. Secrets are never logged at any level — passwords exist only
as Argon2id hashes, bearer tokens are never traced, PAN-rejected values are never echoed, and the
archive passphrase is never written anywhere.
5.1. Environment variables
Every environment variable application.yml reads, alphabetically. These are the deployment surface:
anything in the table above that is not bound to one of these is changed by editing the YAML and
restarting.
| Variable | Default | What it does |
|---|---|---|
|
|
Directory completed |
|
(empty) |
Path to the first-boot JSON ( |
|
|
Schedule for polling the email provider for hard bounces (hourly at :07). Bounces are polled, never received — there is no inbound webhook in this system. |
|
|
MySQL schema name. |
|
|
MySQL host. |
|
(none — required) |
MySQL password. The only variable with no default: the server will not start without it. |
|
|
MySQL user. Needs DDL rights, because Flyway runs migrations at startup. |
|
|
Schedule for the nightly drawing sweep — report hashes still recompute, the draw sequence is gapless, every winner carries two distinct officers, and no winner sits outside the pool the session was opened against. |
|
(empty) |
Transactional email provider token. Set it here and nowhere else — never in |
|
|
Provider endpoint. Must be |
|
|
Master switch for email receipt delivery. Off by default, and the system is fully lawful that way — every receipt simply queues for mail, which is the other channel R9 authorizes. |
|
(empty) |
Sender address. Its domain needs SPF/DKIM/DMARC configured with the provider before you enable email. |
|
(empty) |
Sender display name shown to purchasers. |
|
|
|
|
|
Schedule for re-linking the whole significant-event hash chain (02:00 UTC). A break raises
|
|
|
Whether the server trusts |
|
(empty) |
Path to |
|
|
Schedule for the nightly pool sweep, which recomputes every closed pool at its own closing instant and compares the member set and hash to what was frozen. It never repairs. |
|
|
HTTPS listen port. |
|
|
Schedule for the nightly prize sweep — every winner has a fulfilment record, no prize is held twice, and every session opened against complete evidence. |
|
(empty) |
Path to |
|
|
Schedule for the nightly records sweep — tax-worksheet immutability, archive presence, and that every destruction certificate rests on an approved request and a completed archive. |
|
(empty) |
Log output format. Empty is human-readable text; |
|
(empty) |
Writes the log to this file in addition to standard output, rotating at 100 MB and keeping 30 days capped at 5 GB. Empty means stdout only, which is correct under Kubernetes. |
|
|
Level for everything under |
|
(inherits) |
Level for the drawing-execution area only. Empty inherits |
|
(inherits) |
Level for order intake only. |
|
(inherits) |
Level for payment recording and reconciliation only. |
|
(inherits) |
Level for the drawing-pool eligibility engine only. |
|
(inherits) |
Level for receipt issuance and delivery only. |
|
(inherits) |
Level for WSGC recordkeeping, archives and retention only. |
|
|
Level for the one-line-per-request access log. |
|
|
Page size for catalogue report PDFs ( |
|
|
Serves the administration web interface at |
|
|
Refuses every web-interface request the server believes is plaintext (R15). |
|
|
MySQL port. The one variable in |
The four integrity sweeps are staggered (03:15, 03:25, 03:40, 03:50) so they do not contend, and they run after the 02:00 chain verification. If you move one, keep the ordering: each later sweep assumes the earlier data is settled.
cfoil reads its own separate environment variables, configuring the client rather than the server —
they have no effect on the server. Precedence (highest wins), the same order the client applies
everywhere: a command-line flag, then the environment variable below, then the active profile
(~/.counterfoil/config.json), then the built-in default.
| Variable | Default | What it does |
|---|---|---|
|
|
The server’s base origin. Overridden by the active profile’s |
|
(the profile marked current in |
Which named profile to use for this invocation. Overridden by |
|
(the cached session token for the active profile) |
A bearer token to use instead of the one cached in |
|
|
Where |
|
(none — a usage error if unset) |
The print target for |
5.2. Configuration file examples
Three settings above name a file rather than a value, because what they hold is licensee identity rather than tuning: the server reads each one at startup, validates it, and hashes it. The application never writes any of them.
Every field below is taken from the loader that validates it. A file that is present but invalid fails startup naming the offending field — deliberately, because a mistyped licensee name reaching a document mailed to the public is worse than a server that refuses to boot. An absent file is a different state: it means "not configured yet" and the server starts.
5.2.1. organization.json
The licensee whose name and address print on every receipt (R10). Named by
COUNTERFOIL_ORGANIZATION_FILE.
{
"legalName": "Cascade Health Foundation",
"dbaName": "Cascade Health",
"addressLine1": "1200 Fifth Avenue",
"addressLine2": "Suite 900",
"city": "Seattle",
"state": "WA",
"postalCode": "98101",
"phone": "206-555-0142",
"contactEmail": "raffle@cascadehealth.example.org",
"website": "https://cascadehealth.example.org"
}
| Field | Required | Notes |
|---|---|---|
|
Yes |
The registered legal entity. Printed when there is no |
|
No |
The trading name. When present this is what prints on receipts and counterfoils instead of
|
|
Yes |
|
|
No |
Omit it or leave it out entirely; a blank value is treated the same as absent. |
|
Yes |
|
|
Yes |
Exactly two letters. Not restricted to |
|
Yes |
|
|
Yes |
Printed on receipts, so it must be a number a purchaser can call. |
|
No |
|
|
No |
Nothing secret belongs in this file: every field is public-facing information printed on documents mailed to the public.
Editing it affects only raffles that have not started selling. The profile is frozen onto each raffle at its pre-sale lock, so a correction reaches new raffles and never rewrites the licensee on receipts already issued. Changing the file requires a restart, and the change is recorded in the event log at the next boot.
5.2.2. records.json
The WAC 230-11-105 records custodian, where the records physically live, and the fiscal-year end the
retention clock counts from. Named by COUNTERFOIL_RECORDS_FILE.
{
"custodianName": "Dana Whitfield",
"custodianPhone": "206-555-0187",
"custodianEmail": "records@cascadehealth.example.org",
"addressLine1": "1200 Fifth Avenue",
"addressLine2": "Suite 900",
"city": "Seattle",
"state": "WA",
"postalCode": "98101",
"recordsLocation": "Records room, 1200 Fifth Avenue, Suite 900, Seattle WA",
"fiscalYearEnd": "12-31"
}
| Field | Required | Notes |
|---|---|---|
|
Yes |
The named person responsible for the records, not a department. |
|
Yes |
|
|
No |
|
|
Yes |
|
|
No |
|
|
Yes |
|
|
Yes |
Must be |
|
Yes |
|
|
Yes |
Where a WSGC inspector would physically go. Free text, but write an address a stranger could follow — "Records room, 1200 Fifth Avenue, Suite 900" rather than "head office". |
|
Yes |
|
custodianName, custodianPhone, addressLine1, addressLine2 and recordsLocation are checked
for card numbers at startup, and a file carrying one is refused. It is the same guard the payment
module applies, extended here because this file is typed by hand.
Unlike organization.json, this file is not frozen onto anything: today’s filing names today’s
custodian, which is why the two are separate files. Changing it requires a restart, and the change
is recorded in the event log at the next boot.
5.2.3. The bootstrap admin file
Seeds the first System Administrator so somebody can log in to a brand-new installation. Named by
COUNTERFOIL_BOOTSTRAP_FILE. Unlike the two profiles above, this file is temporary.
{
"email": "admin@cascadehealth.example.org",
"fullName": "Alex Moreau",
"initialPassword": "choose-a-long-unique-passphrase"
}
| Field | Required | Notes |
|---|---|---|
|
Yes |
Becomes the account’s login. Lower-cased on save. |
|
Yes |
|
|
Yes |
Must satisfy the password policy (at least 8 characters). The account is created with "must change password" set, so this credential is replaced at first login. |
|
Warning
|
Delete this file after the first successful boot. It holds a password in plaintext. The server does not delete it for you, and it will not warn you again — the runner is a no-op once any user exists, so a forgotten file is silently harmless to the application and squarely a problem for you. |
Seeding runs only when the users table is empty, which makes it safe on every restart and means
you cannot use this file to add a second administrator or to reset a lost password. Recovering a
locked-out installation is a database operation, not a configuration one.
6. Known limitations
Things a Raffle Administrator should know before they are discovered at an inconvenient moment.
-
The archive passphrase is unrecoverable. No escrow, no hint, no reset. Hold it in your own key custody arrangement. A server restart during an export fails that export unresumably.
-
Frozen artefacts survive destruction. Redacting a purchaser does not remove a winner’s name from an immutable drawing report.
-
The winners-register CSV mis-aligns two of its three sections. The prize and reserve rows are written narrower than the header, so a strictly positional CSV reader mis-reads them. The winner rows — the WSGC register itself — are correct, as are the JSON and PDF forms. Prefer PDF for filing until this is fixed.
-
A failed witness credential check does not currently increment the lockout counter, because the check joins the award transaction that then rolls back. Witness passwords are therefore rate-limited per IP but not lockout-protected.
-
Module 13 is not built. Scheduled and verified backups, a tested restore procedure, signed builds with startup software verification, encrypted-transport scanning, row-level integrity checks and clock-drift alerting are your operational responsibility today, not the system’s. The GLI-31 v1.1 compliance matrix also ships with that module.
-
Three definitions need compliance-lead ratification before your first filing: what "net receipts" means for R19 in your jurisdiction, whether the $10,000 exemption is apportioned across raffles, and whether a prize won but not yet delivered counts as "paid". Counterfoil takes a documented position on each; confirm it is your position too.
-
Deferred by product decision: call-centre vendor sales import (L1), dedicated raffle sales units (L2), hosted multi-instance packaging (L3), refer-a-friend drawings (L4), federal tax reporting and withholding (L5), and out-of-state purchasers (L6).
Rule identifiers (R1–R19, P1) are stable and match Appendix A. They feed the GLI-31 compliance matrix delivered with each release.
Appendix A: Regulatory constraints
Copied in — not linked — so this guide is complete for a reader with no repository access. These 20
rows (R1–R19 plus P1) are the constraint table every module’s acceptance criteria trace back to; they
are reproduced verbatim from features/2026-07-11-counterfoil.md, and a checked-in comparison script
under docs/ fails the build if the two ever diverge. Rule identifiers are stable and match the
GLI-31 compliance matrix delivered with each release.
| # | Constraint | Source | v1? |
|---|---|---|---|
R1 |
Sales only in person, by mail, by fax, or by telephone; website may host printable order forms only |
RCW 9.46.0323(3)(b) |
Yes |
R2 |
No drawing by RNG or similar means; physical draw from tickets |
RCW 9.46.0323; WSGC enhanced raffle guidance |
Yes |
R3 |
Enhanced raffle ticket price ≤ $250 |
WAC 230-11-014(2) |
Yes |
R4 |
Grand prize value ≤ $10,000,000 |
RCW 9.46.0323(3)(a) |
Yes |
R5 |
Eligibility standards for related drawings must be established before any tickets are sold |
RCW 9.46.0323 |
Yes |
R6 |
Early bird tickets, including early bird winners, remain in all subsequent early bird drawings and the grand prize drawing |
RCW 9.46.0323 |
Yes |
R7 |
Refer-a-friend is a completely separate drawing using tickets distinct from enhanced raffle tickets, held at the conclusion of the raffle |
RCW 9.46.0323 |
Deferred (L4) |
R8 |
Purchasers must be 18 or older |
RCW 9.46.0323 / WSGC raffle rules |
Yes |
R9 |
Receipts including ticket confirmation numbers sent by mail or email |
RCW 9.46.0323(3)(j) |
Yes |
R10 |
Tickets numbered consecutively; required disclosures on ticket/handout (price, drawing date/time/location, org name, presence requirement, prize descriptions, minimum prize if percentage-based) |
WAC 230-11-010, 230-11-360, WSGC raffle rules |
Yes |
R11 |
Unencumbered funds ≥ all cash prizes on deposit at time of each drawing; demonstrable availability of noncash prizes |
WAC 230-11-065 |
Yes |
R12 |
Detailed records in WSGC standard format; retained 3 years from end of fiscal year (ticket stubs and unsold tickets: 1 year); records maintained in Washington; drawing’s printed tickets kept ≥ 30 days or until prize awarded |
WAC 230-11-100, 230-11-105; WSGC enhanced raffle guidance |
Yes |
R13 |
No duplicate draw numbers within the same raffle event; voided tickets clearly identifiable |
GLI-31 §2.3.3, ticket requirements |
Yes |
R14 |
Significant-event logging with an interrogation program supporting comprehensive online search of recorded events |
GLI-31 §5.6 |
Yes |
R15 |
Critical data integrity via checksums/signatures/validity checks; encrypted communications for raffle-critical data |
GLI-31 §3.4, Ch. 5 |
Yes |
R16 |
Reliable system clock; backup and recovery; verification of installed system software |
GLI-31 Ch. 5 |
Yes |
R17 |
Raffle Drawing Report and electronic accounting/reporting per GLI-31 |
GLI-31 §2.6 |
Yes |
R18 |
Discounted/bundle pricing plans permitted; records must determine gross gambling receipts from discounted sales per WSGC modified standard format |
WAC 230-11-085, 230-11-086; WSGC recordkeeping packet |
Yes |
R19 |
Cities/counties/towns may tax raffles at ≤ 5% of net receipts; first $10,000 of net proceeds exempt; rate is jurisdiction-specific and configured per raffle |
RCW 9.46.110; local ordinance (King County for launch) |
Yes |
P1 (policy) |
v1 restricts purchasers to Washington residents |
Stakeholder decision (OQ8) |
Yes |
Appendix B: REST API reference
The canonical definition of Counterfoil’s REST API — the consolidated contract clients build against. The API is organized by component; each component’s module tech spec is the source of truth for its behavior.
-
Style: JAX-RS (Jersey) resources over HTTPS. Synchronous request/response, JSON only.
B.1. Components
| Component | Base path | What it does |
|---|---|---|
|
Issues and revokes the opaque session tokens every other call requires; self-service password change. |
|
|
System Administrator management of accounts, roles, lockouts, and retirement. |
|
|
Read-only interrogation of the append-only, hash-chained audit log, and chain verification. |
|
|
The enhanced raffle’s legal parameters — raffle, pricing plans, drawings, prizes, tax rate — with the irreversible pre-sale lock and compliance checklist. |
|
|
Local gambling-tax rate lookup feeding the raffle-creation suggestion. Read by any role; maintained by the System Administrator. |
|
|
The sales-entry API: quote, place a complete order (minting its draw numbers), read, and correct. Raffle-scoped purchaser search and correction. |
|
|
System Administrator registry of the stations and devices permitted to submit orders. |
|
|
The append-only payment ledger — record, reverse, read. Record-only: Counterfoil never processes a payment and holds no cardholder data. |
|
|
The two-person refund control; approval returns the money and voids every ticket on the order in one transaction. |
|
|
Receipts by method/channel and the R18 gross-receipts breakout by pricing-plan option; gross, prize exposure and provisional net. |
|
|
Immutable receipt snapshots with every R9/R10 disclosure, their PDF, and their delivery by email or mail. |
|
|
Mail print runs: generate, download the batch PDF, confirm printed, or abandon a jam. |
|
|
Read-only view of the licensee this server sells for. Configured by file, not by API. |
|
|
The physical drawing pool: cut and freeze a barrel, read its manifest, and check it against the eligible pool. |
|
|
Open a run over a range of stubs, stream it to a printer, and confirm or abandon it. Streaming marks nothing. |
|
|
Which tickets are in which drawing, computed from the locked eligibility standard — and the one irreversible closure that freezes it. |
|
|
Conducting the physical drawing: the readiness gate, the ordered draw sequence, the two-person winner control, and the Raffle Drawing Report. |
|
|
WAC 230-11-065 prize evidence, the append-only reserve-balance log, the R11 check, and post-drawing fulfilment. |
|
|
The WSGC standard raffle record set, and the ten internal identities a filing must satisfy. |
|
|
The R19 local gambling-tax filing: previewed on demand, finalized once, frozen forever. |
|
|
Every report in the product, in JSON, CSV and PDF, with one cover page and one export event. |
|
|
Encrypted per-raffle record exports for in-state records custody. Verified and extracted offline. |
|
|
R12 retention clocks and the two-person destruction control. Destruction is redaction under a permanent certificate. |
|
|
Read-only view of the WAC 230-11-105 records custodian. Configured by file, not by API. |
System-integrity operations (Module 13) arrive with a later release (see the product PRD and Appendix C).
B.2. Conventions
These apply to every endpoint unless a section states otherwise.
B.2.1. Base path & versioning
-
All endpoints are under
/api/v1. The version is in the path; breaking changes ship under a new prefix (/api/v2), never by mutating an existing contract. -
Paths are lower-case, plural nouns for collections (
/users), with sub-actions as verbs on a resource (/users/{id}/unlock).
B.2.2. Media types
-
Requests and responses are
application/json; charset=utf-8unless noted (e.g. future PDF/CSV report exports returnapplication/pdf/text/csv). -
Unknown JSON fields in a request body are rejected (
400 VALIDATION_ERROR) rather than silently ignored, so typos surface early.
B.2.3. Transport
-
HTTPS only. Raffle-critical data is never carried over plaintext (GLI-31 §3.4 / R15). Requests over plain HTTP are refused/redirected.
The administration web interface at /ui/ (see Section 4.4) is a client of this API, served
from the same server on the same port. It calls the resources documented here in-process and adds no
endpoint, request field, response field or error code to /api/v1. Its session cookie is never
accepted here: this API authenticates from the Authorization header alone, so a request carrying
only the cookie is 401, which is what keeps /api/v1 free of cross-site request forgery.
B.2.4. Authentication
-
All endpoints require authentication except
POST /api/v1/auth/login. -
Auth uses an opaque bearer token issued by
POST /auth/login, sent on every subsequent request:Authorization: Bearer <token> -
Tokens are server-side sessions: revocable, subject to idle and absolute timeouts (defaults 30 min idle / 12 h absolute, configurable). An expired, revoked, or unknown token →
401 UNAUTHENTICATED. -
There is no anonymous write path anywhere in the system.
B.2.5. Authorization
-
Role-based. A caller’s roles (union across all assigned roles) must include one permitted for the endpoint, else
403 FORBIDDEN. -
Roles are a fixed enum (see Section B.3). The
AUDITORrole is read-only across the entire API;SYSTEM_ADMINISTRATORmanages accounts and system ops but holds no gaming-record write authority (segregation of duties). -
Every
403on a mutating endpoint emits aPERMISSION_DENIEDsignificant event.
B.2.6. Request correlation
-
Every response carries an
X-Request-Idheader — success, error and streamed body alike. It is the correlation id stamped on every server-side log line produced while serving that request. -
Quote it when reporting a problem: it is what turns "the export failed this morning" into the exact lines the server wrote.
-
A client may supply its own
X-Request-Idon the request (8–64 characters of[A-Za-z0-9_-]) and it will be adopted and echoed. A supplied value is used for correlation and nothing else — it grants no authority and is never trusted as an identifier. Anything malformed is ignored and the server mints its own. -
cfoil --verboseprints the returned id, the status and the elapsed time to stderr.
B.2.7. Attribution & timestamps
-
Every persisted record is stamped with the authenticated actor and a server-clock UTC timestamp. There is no way to create an unattributed record.
-
All timestamps in requests and responses are ISO-8601 UTC (
2026-07-13T18:22:05.123Z). -
Who acted is returned beside the id. Wherever a void-request, refund-request, destruction or event response carries a user id (
requestedBy…,approvedBy…,actorUserId), it also carries that user’s identity in a companion field named for the role, with the suffixIdentity(requestedByIdentity,approvedByIdentity,actorIdentity), in the formFull Name <email>— for example"Dana Whitfield <dana@example.org>". -
An identity is resolved from the user’s current name and email when the response is produced. It is never stored and never written to the event chain: every record keeps only the id, so a renamed user reads under their new name and the id is what attributes the record. A
DISABLEDuser’s identity is still returned (R12). It is a display string; filter and join on the id beside it. -
An identity is
null(omitted where the response omits nulls) when there is no user — a system event — and on every sign-in event (LOGIN_SUCCESS,LOGIN_FAILURE,ACCOUNT_LOCKOUT), for privacy: there the actor is the account signing in, and the event summary deliberately masks its email.actorUserIdis still returned on those events. -
GET /api/v1/usersstaysSYSTEM_ADMINISTRATOR-only. Identities are shown only where a record a caller may already read references a user.
B.2.8. Money & rates
-
Money fields are JSON strings in dollars with two decimals (
"250.00"). -
Tax rates and percentages are decimal-fraction strings (5% =
"0.0500"); derived per-ticket rates carry four decimals ("83.3333").
B.2.9. Pagination (collections)
-
List endpoints accept
?page=<0-based>&size=<n>(defaultpage=0,size=50, maxsize=200). -
List responses are enveloped:
{ "content": [ /* items */ ], "page": 0, "size": 50, "totalElements": 137, "totalPages": 3 }
B.2.10. Error shape
All non-2xx responses share one envelope:
{
"error": {
"code": "MACHINE_CODE",
"message": "Human-readable explanation.",
"rule": "R8", // optional — the regulatory rule/citation violated
"details": { } // optional — field-level validation info
}
}
B.2.11. Common status codes
| Code | Meaning | error.code examples |
|---|---|---|
|
Malformed / invalid request |
|
|
Not authenticated (missing/expired/invalid token or bad credentials) |
|
|
Authenticated but not permitted |
|
|
Resource not found |
|
|
Conflict with current state |
|
|
Resource locked |
|
|
Rate limited |
|
|
Backing store unavailable (fail-closed; no degraded writes) |
|
B.3. Roles
Fixed enum (no runtime editing in v1). Used verbatim as the authorization role string.
| Role | Summary |
|---|---|
|
Full raffle configuration, drawing execution, reporting. |
|
Order entry and receipt issuance; no config or unilateral void authority. |
|
Payment recording, reconciliation, reports, exports. |
|
Read-only across all records, logs, and reports. Cannot mutate anything. |
|
User administration and system ops. Segregated from gaming-record edits. |
A user holds one or more roles; effective permissions are the union.
B.4. Authentication & sessions
Issues, inspects, and revokes the opaque server-side session tokens that authenticate every other call, and handles self-service password change. Defined by the identity & access-control module spec.
Login key is email (unique, case-insensitive, normalized to lower-case).
B.4.1. POST /api/v1/auth/login
Authenticate and receive a session token. Unauthenticated. Rate-limited per-IP.
Request:
{
"email": "admin@org.example",
"password": "•••••••••",
"salesUnitId": "STATION-03" // optional; identifies the workstation/RSU
}
200 OK:
{
"token": "<opaque-session-token>",
"expiresAt": "2026-07-14T06:22:05.000Z",
"user": {
"id": 42,
"email": "admin@org.example",
"roles": ["SYSTEM_ADMINISTRATOR"]
},
"mustChangePassword": false
}
The raw token is returned once; store it and send it as the bearer token thereafter.
Errors:
| Status | code |
When |
|---|---|---|
|
|
Unknown email or wrong password (identical response either way — no account enumeration). |
|
|
Account locked after 5 consecutive failures; needs a System Administrator unlock. |
|
|
Per-IP login burst limit exceeded. |
Emits LOGIN_SUCCESS or LOGIN_FAILURE; the failure that crosses the lockout threshold also emits
ACCOUNT_LOCKOUT.
B.4.2. POST /api/v1/auth/logout
Revoke the caller’s current session. Any authenticated role.
204 No Content.
B.4.3. GET /api/v1/auth/me
Return the current principal. Any authenticated role.
200 OK:
{
"id": 42,
"email": "admin@org.example",
"fullName": "Dana Admin",
"roles": ["SYSTEM_ADMINISTRATOR"],
"salesUnitId": "STATION-03"
}
B.4.4. POST /api/v1/auth/password
Change the caller’s own password. Any authenticated role (self).
Request:
{ "currentPassword": "•••", "newPassword": "•••" }
204 No Content. On success, all of the caller’s other sessions are revoked and
mustChangePassword is cleared.
Errors: 400 WEAK_PASSWORD (with policy detail in error.details), 401 INVALID_CREDENTIALS (wrong
currentPassword).
Password policy (v1): minimum length ≥ 8; forced change on first login. Complexity and breached-password checks are not in v1 and are a required pre-production / pre-certification gate.
B.5. User administration
Account lifecycle for the five fixed roles: creation, role assignment, lockout recovery, password reset, and retirement. Defined by the identity & access-control module spec.
All endpoints require SYSTEM_ADMINISTRATOR. Users are never hard-deleted — retirement is disable
(attribution must survive for the records' retention life, R12).
| Method & path | Purpose | Success | Errors |
|---|---|---|---|
|
Create a user |
|
|
|
List users (paged; |
|
— |
|
Fetch one user |
|
|
|
Update |
|
|
|
Clear a lockout, reset failure count |
|
|
|
Set a new password; forces change on next login |
|
|
|
Retire a user; revokes all their sessions |
|
|
POST /api/v1/users request:
{
"email": "sam.operator@org.example",
"fullName": "Sam Operator",
"roles": ["SALES_OPERATOR"],
"initialPassword": "•••" // optional; if omitted, account is created requiring a reset flow
}
User resource (returned by create / fetch / update):
{
"id": 57,
"email": "sam.operator@org.example",
"fullName": "Sam Operator",
"roles": ["SALES_OPERATOR"],
"status": "ACTIVE", // ACTIVE | LOCKED | DISABLED
"mustChangePassword": true,
"lastLoginAt": null,
"createdAt": "2026-07-13T18:22:05.123Z",
"createdBy": 42,
"updatedAt": "2026-07-13T18:22:05.123Z",
"updatedBy": 42
}
Password fields are never present in any response. User-admin mutations emit RECORD_CREATE /
RECORD_MODIFY significant events (entity_type=users) with email masked in the event summary.
B.6. Significant event log & interrogation
The system’s audit backbone: an append-only, hash-chained log of significant events — every event carries the hash of the prior event, so any edit, deletion, or reorder is detectable — with an interrogation search (R14) and chain verification. Defined by the event-log module spec.
Events are written only internally (by every component, through the event port) — there is no create/update/delete endpoint. These endpoints are read/observe only.
Access is limited to the read-only reviewer and operator roles: AUDITOR, RAFFLE_ADMINISTRATOR,
SYSTEM_ADMINISTRATOR. Running a verification is a system operation restricted to
SYSTEM_ADMINISTRATOR — it appends an EVENT_CHAIN_* event, so it is a state change an Auditor may
not cause (Auditors read the outcome instead).
B.6.1. Interrogation search
GET /api/v1/events
Search the event log (R14). All filters are optional and combinable; results are paged (newest first).
Query parameters:
| Param | Meaning |
|---|---|
|
ISO-8601 UTC bounds on the event’s UTC timestamp; half-open |
|
Repeatable; matches any of the listed types (e.g. |
|
Numeric user id, or the literal |
|
Exact match on the affected entity (e.g. |
|
Standard pagination ( |
|
|
200 OK — paged envelope of event views:
{
"content": [
{
"seq": 128,
"id": 128,
"eventType": "RECORD_MODIFY",
"occurredAtUtc": "2026-07-15T18:22:05.123Z",
"occurredAtLocal": "2026-07-15T18:22:05.123",
"actorUserId": 42,
"actorIdentity": "Sam Ortiz <sam@example.org>",
"entityType": "users",
"entityId": "57",
"summary": { "changed": { "status": "DISABLED" } },
"prevHash": "9f2c…",
"eventHash": "1a7b…",
"hashVersion": 1
}
],
"page": 0, "size": 50, "totalElements": 1, "totalPages": 1
}
Errors: 400 VALIDATION_ERROR — malformed from/to, from after to, unknown eventType,
non-numeric actorUserId, unknown sort, or size < 1.
B.6.2. Chain verification
POST /api/v1/events/verification
Verify the hash chain now. Role: SYSTEM_ADMINISTRATOR (system operation).
Small chains verify inline and return 200 OK; chains beyond the configured inline budget run on a
background worker and return 202 Accepted with the run id (status RUNNING); poll
verification/latest for the outcome.
200 OK / 202 Accepted:
{
"runId": 7,
"status": "PASS", // RUNNING | PASS | BROKEN | ERROR
"verifiedThroughSeq": 128,
"brokenAtSeq": null, // present only when status = BROKEN
"checkedCount": 128,
"startedAt": "2026-07-15T18:25:00.000Z",
"finishedAt": "2026-07-15T18:25:00.412Z",
"trigger": "ON_DEMAND"
}
A BROKEN result also emits an EVENT_CHAIN_BROKEN significant event (itself chained) and surfaces
on the admin dashboard.
GET /api/v1/events/verification/latest
Return the most recent verification run (for dashboards and audit review). Roles: AUDITOR,
RAFFLE_ADMINISTRATOR, SYSTEM_ADMINISTRATOR.
200 OK — same shape as above. 404 NOT_FOUND if no run has been recorded yet.
B.6.3. Event types
The full GLI-31 §5.6 catalog is declared; each type is emitted by the component that owns the
underlying action (authentication, configuration, and the chain lifecycle today; drawing/finance/ops
types as their components ship):
LOGIN_SUCCESS, LOGIN_FAILURE, ACCOUNT_LOCKOUT, PERMISSION_DENIED, RECORD_CREATE,
RECORD_MODIFY, RECORD_VOID, CONFIGURATION_CHANGE, CONFIGURATION_LOCK,
CONFIGURATION_LOCKED_ATTEMPT, DRAWING_OPENED, DRAWING_CLOSED, WINNER_RECORDED,
REPORT_GENERATED, EXPORT_PRODUCED, BACKUP_SUCCESS, BACKUP_FAILURE, RESTORE_PERFORMED,
CLOCK_ADJUSTMENT, SOFTWARE_VERIFICATION_PASS, SOFTWARE_VERIFICATION_FAIL, SYSTEM_STARTUP,
EVENT_CHAIN_VERIFIED, EVENT_CHAIN_BROKEN.
B.7. Raffle configuration
The enhanced raffle’s legal parameters of record: the raffle itself, pricing plans (single + bundles, R3/R18), drawings with typed eligibility parameters (R5), fixed-value prizes (R4/R10; percentage-of-receipts prizes are reserved, non-selectable in v1), and the local gambling-tax rate (R19) — all governed by the irreversible pre-sale lock. Defined by the raffle-configuration module spec.
Lock semantics (R5): when the first ticket is issued (order intake), the configuration transitions
irreversibly to LOCKED. Thereafter every mutating configuration endpoint returns
409 CONFIGURATION_LOCKED and emits a CONFIGURATION_LOCKED_ATTEMPT significant event. There is no
unlock and no manual-lock endpoint. Exception: the tax rate (PUT /raffles/{id}/tax-rate) remains
editable post-lock until the tax worksheet is finalized (409 TAX_RATE_FINALIZED thereafter). A
raffle cannot lock — cannot sell its first ticket — until licenseNumber is recorded
(409 LICENSE_REQUIRED surfaces through the issuance flow).
Authorization: reads are open to all five roles (raffle parameters are public ticket disclosures).
Writes are RAFFLE_ADMINISTRATOR only — segregation of duties: SYSTEM_ADMINISTRATOR and AUDITOR
hold no gaming-record write authority.
Representation: regulatory violations return 400 RULE_VIOLATION with error.rule (e.g. "R3")
and error.details.citation; money and rates follow the Money & rates convention.
B.7.1. Raffles
| Method & path | Purpose | Success | Errors |
|---|---|---|---|
|
Create a raffle (DRAFT) |
|
|
|
List (paged; |
|
— |
|
Full config: raffle + tax + |
|
|
|
Edit core fields (not tax rate). DRAFT only. |
|
|
|
Set jurisdiction + effective rate. Allowed in DRAFT and LOCKED, until worksheet finalization |
|
|
|
Pre-lock pass/fail line items: R3, R4, R5, R10, R18, LICENSE |
|
|
POST /api/v1/raffles request:
{
"name": "2026 Dream House Raffle",
"licenseNumber": "ER-000123", // optional in DRAFT; required before the first sale
"salesOpenAt": "2026-08-01T07:00:00.000Z",
"salesCloseAt": "2026-11-30T08:00:00.000Z",
"totalTicketCount": 40000,
"timeZone": "America/Los_Angeles", // optional; default America/Los_Angeles
"tax": {
"city": "Seattle",
"county": "King",
"state": "WA",
"effectiveRate": "0.0500"
}
}
The server resolves the jurisdiction suggestion and records suggestedRate, effectiveRate, and
overridden on the raffle’s tax block. Rates above 5% (0.0500) are rejected (RULE_VIOLATION,
rule R19).
B.7.2. Pricing plans (DRAFT-only; RAFFLE_ADMINISTRATOR)
Single-ticket and multi-ticket bundle pricing (WAC 230-11-085/-086). The discounted per-ticket rate is derived and recorded at save for R18 reporting.
| Method & path | Purpose | Success | Errors |
|---|---|---|---|
|
Add |
|
|
|
Edit label / count / price; rate re-derived and re-recorded |
|
as above, |
|
Soft removal ( |
|
|
R3: price ≤ 250.00 × ticketCount (exact arithmetic; covers the single-ticket cap and the bundle
effective rate).
B.7.3. Drawings & prizes (DRAFT-only; RAFFLE_ADMINISTRATOR)
The drawing structure and its eligibility standards, fixed before the first sale (R5), plus each drawing’s prizes (R4/R10).
| Method & path | Purpose | Success | Errors |
|---|---|---|---|
|
Add |
|
|
|
Edit; same per-type validation |
|
as above, |
|
Soft removal; cascades |
|
|
|
Add |
|
|
|
Edit prize |
|
as above, |
|
Soft removal |
|
|
Drawing types: GRAND (exactly one active per raffle; no cutoff — its pool is structurally the sales
close; must be scheduled at or after sales close), EARLY_BIRD (requires entryCutoffAt within the
sales window), MULTIPLE_TICKET (requires entryCutoffAt + minTicketCount ≥ 2), REFER_A_FRIEND
(reserved for a later release — rejected with FEATURE_NOT_AVAILABLE). R4: the GRAND drawing’s active
prize values may total at most $10,000,000.
B.7.4. Compliance checklist
GET /api/v1/raffles/{id}/compliance-checklist
The Raffle Administrator’s pre-flight view (any role may read). The lock trigger is the first sale, not checklist success.
200 OK:
{
"raffleId": 1,
"configStatus": "DRAFT",
"evaluatedAt": "2026-07-16T18:00:00.000Z",
"allPass": false,
"items": [
{ "rule": "R3", "citation": "WAC 230-11-014(2)", "description": "≥1 pricing plan; every plan ≤ $250/ticket", "status": "PASS" },
{ "rule": "R4", "citation": "RCW 9.46.0323(3)(a)", "description": "Grand prize total ≤ $10M", "status": "PASS" },
{ "rule": "R5", "citation": "RCW 9.46.0323", "description": "Grand drawing defined; every drawing's eligibility standards complete", "status": "FAIL", "detail": "No GRAND drawing configured." },
{ "rule": "R10", "citation": "WAC 230-11-010/-360", "description": "Disclosures complete: date/time/location, prize descriptions, presence flags", "status": "PASS" },
{ "rule": "R18", "citation": "WAC 230-11-085/-086", "description": "Every bundle's discounted per-ticket rate computed and recorded", "status": "PASS" },
{ "rule": "LICENSE", "citation": "RCW 9.46.0323", "description": "WSGC license number recorded (required before first sale)", "status": "FAIL", "detail": "license_number not set." }
]
}
Raffle-configuration mutations emit RECORD_CREATE / CONFIGURATION_CHANGE significant events
(entity_type = raffle / pricing_plan / drawing / drawing_prize); the lock emits
CONFIGURATION_LOCK; post-lock attempts emit CONFIGURATION_LOCKED_ATTEMPT.
B.8. Tax jurisdictions
Reference data: the city/county/state local gambling-tax lookup that suggests a rate at raffle creation (R19). Defined by the raffle-configuration module spec. All roles may read; the System Administrator maintains it.
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
List jurisdiction entries (paged) |
|
|
all authenticated roles |
Most-specific-match rate suggestion (city → county → state, effective today) |
|
|
|
Append an effective-dated rate row |
|
|
|
Correct a row not yet in effect |
|
A rate change is an append, never an edit. The suggestion already picks the latest row in effect, so
history is preserved by construction and yesterday’s suggestion stays reproducible. A row that has
taken effect is history — a raffle may have been configured from it — so it is superseded by a new row
rather than edited, and PATCH refuses with 409 JURISDICTION_IN_EFFECT. There is no DELETE: a
jurisdiction that stops taxing raffles gets a new row at 0.0000.
Why a System Administrator write is not a breach of segregation of duties. This is reference data about municipal ordinances — the same kind of fact as a timezone database — not a gaming record. A write here touches no raffle, order, ticket, payment, pool, drawing or winner, and it is structurally incapable of altering a raffle’s figures: the jurisdiction is snapshotted onto the raffle at configuration and frozen again onto the tax worksheet at finalization. Giving it to the Raffle Administrator instead would let the person who configures a raffle move the rate it was suggested.
GET …/suggestion 200 OK:
{ "matched": true, "jurisdictionId": 1, "matchLevel": "COUNTY", "rate": "0.0500",
"citation": "King County Code ch. 4A.100; RCW 9.46.110(3) 5% ceiling — rate pending compliance-lead verification" }
{ "matched": false } is a normal answer — the administrator enters the rate directly.
B.9. Ticket inventory & draw numbers
The single source of draw numbers: consecutive, gapless, unique per raffle, with clearly-identified voids (R10, R13). Defined by the ticket-inventory module spec.
Issuance has no HTTP endpoint. A draw number is minted only through order completion (order intake), which calls the internal issuance seam inside its transaction; that call also triggers the first-sale configuration lock (R5). There is deliberately no manual-mint path — mirroring the no-manual-lock rule.
Voids are a two-person control (R13, AC #4): one user (Sales Operator or higher) opens a void
request with a controlled reason and free text; a different Raffle Administrator or Finance/Records
Officer approves it (409 VOID_APPROVAL_SELF if the same user). Only an approved void flips the
ticket to VOID — the number is kept, never freed, and the row stays permanently visible in inventory
and reports. A pending void does not change eligibility.
Authorization: reads (inventory, ticket lookups, void-request queue) are open to all five roles (the
Auditor must read inventory). Void writes are gaming-record mutations — SYSTEM_ADMINISTRATOR and
AUDITOR hold no write authority.
B.9.1. Inventory & ticket reads (all authenticated roles)
| Method & path | Purpose | Success | Errors |
|---|---|---|---|
|
Reconciling report: per-series and totals, |
|
|
|
Paged ticket list; filters |
|
|
|
Look up one ticket by its printed draw number, with its |
|
|
GET …/ticket-inventory 200 OK:
{ "raffleId": 1, "configuredTotal": 50000,
"series": [ { "code": "ENHANCED", "prefix": "", "totalCount": 50000, "startNumber": 1,
"nextDrawNumber": 1204, "allocated": 1203, "unissued": 48797, "issued": 1190,
"voided": 13, "voidPending": 2 } ],
"totals": { "unissued": 48797, "issued": 1190, "voided": 13, "total": 50000 },
"reconciles": true }
B.9.2. Voids (two-person control)
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Initiate |
|
|
|
|
Approve → ticket |
|
|
|
|
Reject → request |
|
|
|
all authenticated roles |
Work queue; filter |
|
|
Every void-request response carries the ticket’s drawNumber beside its ticketId, and the
requestedByIdentity / approvedByIdentity of the two people the control records.
Issuance emits RECORD_CREATE (entity_type = ticket, summary carries the draw-number range); an
approved void emits RECORD_VOID (entity_type = ticket, carrying requestedBy + approvedBy
reason + draw number); a void request emits RECORD_CREATE (entity_type = ticket_void_request)
and a rejection RECORD_MODIFY. No new event types.
B.10. Order intake
The sales-entry API (Module 5) — the one versioned surface through which every order enters the system, whatever channel it arrived on. The operator UI is a client of these endpoints, not a privileged path beside them, which is what lets a future call-center feed (L1) or RSU device (L2) become an additional registered client rather than additional code.
An order and its draw numbers are created together, in one transaction, or not at all. There is no draft order, no reservation, and no endpoint anywhere that mints a draw number on its own. If any step fails — closed window, exhausted inventory, unlicensed raffle — the whole order rolls back and no number is consumed.
Prices are never client-supplied. A request names a pricingPlanId and a quantity; the server
reads the locked plan and derives the money. No request body in this section has a price or amount
field. Each stored line keeps a snapshot of the plan’s terms, including the R18 discounted per-ticket
rate.
B.10.1. Replay safety (required)
Every create carries a client-generated clientRequestId, unique per sales unit. Re-sending an
identical request returns 200 OK with the original order instead of 201 — the draw numbers are
not minted twice. This is mandatory, not optional: a duplicate submit would consume numbers
permanently, and undoing it takes a two-person void per ticket. Concurrent duplicates are resolved by
a database unique key, so exactly one wins and the rest receive the winner’s order.
B.10.2. Orders
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Advisory quote: totals, per-line R18 rates, sales-window state, duplicate candidates. Writes nothing |
|
|
|
|
Place a complete order and mint its draw numbers |
|
|
|
as above |
Same |
|
— |
|
all authenticated roles |
List, paged. |
|
|
|
all authenticated roles |
One order with its lines and issued draw numbers |
|
|
|
|
Correct |
|
|
PATCH exposes no pricing, quantity, channel, purchaser or sales-unit field by design: changing what
was sold means voiding tickets through the two-person control, not editing the record.
Create request. channel is restricted to exactly IN_PERSON, MAIL, FAX, TELEPHONE (R1) —
there is no online value anywhere in the system. sourceReference is required for MAIL and FAX
(a source document exists and must be matchable at audit). attested18 must be true (R8); the
attesting operator’s id and the attestation timestamp are recorded on the order. Supply exactly one of
purchaser.purchaserId (reuse) or the inline identity fields.
{ "clientRequestId": "0f5b3c2a-...-uuid",
"salesUnitCode": "WS-FRONTDESK-01",
"channel": "TELEPHONE",
"sourceReference": "CALL-2026-07-25-0417",
"attested18": true,
"note": null,
"purchaser": { "purchaserId": null,
"acknowledgeDuplicate": false,
"fullName": "Jane Q. Public",
"addressLine1": "1200 Fifth Ave", "addressLine2": null,
"city": "Seattle", "state": "WA",
"postalCode": "98101", "postalCodeExt": "1234",
"phone": "+1 206 555 0134", "email": "jane@example.org" },
"lines": [ { "pricingPlanId": 2, "quantity": 2 },
{ "pricingPlanId": 1, "quantity": 1 } ] }
Create response 201. orderReference is derived from the immutable order id. It is stable but
not gapless (a rolled-back order leaves a gap) and must not be read as a sales count — R10’s
consecutive-numbering obligation applies to draw numbers, not order references.
{ "id": 1, "orderReference": "ORD-1",
"raffleId": 1, "status": "COMPLETED",
"channel": "TELEPHONE", "salesUnitCode": "WS-FRONTDESK-01",
"clientRequestId": "0f5b3c2a-...-uuid",
"sourceReference": "CALL-2026-07-25-0417",
"placedAt": "2026-07-25T20:19:41.271Z",
"attested18": true, "attestedByUserId": 3, "attestedAt": "2026-07-25T20:19:41.271Z",
"note": null,
"purchaser": { "id": 1, "raffleId": 1, "fullName": "Jane Q. Public",
"addressLine1": "1200 Fifth Ave", "addressLine2": null,
"city": "Seattle", "state": "WA", "postalCode": "98101",
"postalCodeExt": "1234", "phone": "+1 206 555 0134",
"email": "jane@example.org", "duplicateAckOfId": null },
"lines": [ { "id": 1, "pricingPlanId": 2, "planLabel": "3 for $250", "planTicketCount": 3,
"planPrice": "250.00", "planPerTicketRate": "83.3333",
"quantity": 2, "lineTicketCount": 6, "lineAmount": "500.00" },
{ "id": 2, "pricingPlanId": 1, "planLabel": "Single", "planTicketCount": 1,
"planPrice": "100.00", "planPerTicketRate": "100.0000",
"quantity": 1, "lineTicketCount": 1, "lineAmount": "100.00" } ],
"ticketCount": 7, "totalAmount": "600.00",
"drawNumbers": ["00001","00002","00003","00004","00005","00006","00007"] }
B.10.3. The duplicate-purchaser prompt
Keying a purchaser whose normalized name and 5-digit postal code match an existing record returns
409 PURCHASER_MATCH_FOUND with the candidates. This is a prompt, not a block: the same call
resolves it in one further round-trip, with no privileged path and no override role. It is enforced
server-side so an API-only client gets the control without implementing it.
{ "error": { "code": "PURCHASER_MATCH_FOUND",
"message": "An existing purchaser matches this name and postal code.",
"details": { "candidates": [ { "purchaserId": 1, "fullName": "Jane Q. Public",
"addressLine1": "1200 Fifth Ave", "city": "Seattle",
"state": "WA", "postalCode": "98101",
"phone": "+1 206 555 0134", "orderCount": 1 } ],
"resolve": "resubmit with purchaser.purchaserId to reuse, or acknowledgeDuplicate=true to create a new record" } } }
Resubmit with either purchaser.purchaserId (reuse — the usual answer, and what keeps a buyer’s
ticket count whole for the Module 9 multiple-ticket threshold) or acknowledgeDuplicate: true
(genuinely a different person). The acknowledged branch stamps duplicateAckOfId on the new
purchaser, so an auditor can tell an answered prompt from one that never fired.
B.10.4. Purchasers
Purchasers are scoped to their raffle: retention (R12) and the Module 12 archive are per-raffle cuts. A buyer active in two raffles is two records.
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
Search, paged. |
|
|
all authenticated roles |
One purchaser |
|
|
|
Correct name / address / phone / email |
|
Errors on the PATCH: 400 VALIDATION_ERROR, 400 RULE_VIOLATION (P1), 404 NOT_FOUND. A
correction re-runs the residency policy — a typo fix must not become an out-of-state back door — and
emits RECORD_MODIFY with masked before/after.
B.10.5. Residency policy (P1)
v1 accepts Washington mailing addresses only: state must be WA and the 5-digit postal code must
fall in 98001–99403. A violation is 400 RULE_VIOLATION with error.rule = "P1" and the citation
in details. The postal-code range is a transcription guard, not residency evidence — it accepts
unassigned codes inside the range; the order form’s attestation is the evidence of record. Relaxing
this (L6) is an implementation change and a re-signed build, not a configuration toggle.
B.10.6. Events
Placing an order emits RECORD_CREATE (entity_type = raffle_orders) carrying the channel, sales
unit, plan options with their R18 rates, totals, and the first/last draw number. Creating a purchaser
emits RECORD_CREATE (entity_type = purchasers); correcting an order or purchaser emits
RECORD_MODIFY. Purchaser identifiers are masked in every summary ("J* Q* P",
"*0134", "j@e*.org"), and street lines and full postal codes never appear.
A refused order emits the new ORDER_REJECTED type, committed independently so it survives the
rejected transaction’s rollback. summary.reason is one of SALES_WINDOW_CLOSED, RESIDENCY,
ATTESTATION, INVENTORY_EXHAUSTED, LICENSE_REQUIRED — one filter answers "which sales did this
system refuse, and why".
B.11. Sales units
The registry of stations and devices permitted to submit orders. Every order records the
sales_unit_id it came from, which is the seam that lets L1 vendor feeds and L2 RSU devices arrive as
registered clients of the same order API.
Segregation. This is administered by the SYSTEM_ADMINISTRATOR because it is station/device
inventory, not a gaming record — the row holds no purchaser, money, or ticket data, and GLI-31 Ch. 3
treats sales-unit registration as a system-administration function. That role has no write authority
on orders, purchasers, or tickets.
Deployment checklist: at least one ACTIVE WORKSTATION must be registered before the first sale,
or every order is refused with 409 SALES_UNIT_UNKNOWN.
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
|
Register |
|
|
|
List (paged; |
|
|
|
Retire a station; its orders stay attributed |
|
Errors: 400 VALIDATION_ERROR (unknown type, duplicate code), 400 FEATURE_NOT_AVAILABLE (RSU,
VENDOR_FEED) on create; 404 NOT_FOUND on retire.
type is WORKSTATION in v1. RSU (L2) and VENDOR_FEED (L1) are declared but not registrable —
attempting either returns FEATURE_NOT_AVAILABLE, so those phases add rows rather than a migration.
Registration and retirement emit RECORD_CREATE / RECORD_MODIFY (entity_type = sales_units).
B.12. Payments (record-only)
Counterfoil never processes a payment. Card, check and cash handling all happen on the organization’s existing merchant facilities; this module records what happened, when, and the processor’s transaction-ID reference. There is no gateway client, no webhook endpoint, no settlement importer and no tokenization vault anywhere in the system — so "the payment processor is down" changes nothing here.
No column can hold a primary account number. cardLast4 is exactly four digits, and every
operator-supplied free-text field is rejected if it contains a 13–19 digit sequence (spaces and
hyphens between digits are ignored, so 4111 1111 1111 1111 is caught). The rejected value is never
echoed back, logged, or written to the event log. This is the PRD’s literal rule rather than a Luhn
check; the known cost is that a purely numeric 13–19 digit processor reference is refused.
B.12.1. The ledger
payments is append-only and signed. There is no PATCH and no DELETE on an entry, by design:
SUM(amount) for an order is its amount paid, and the record shows what was recorded and later
corrected, not just the final number.
| Entry type | Sign | Touches tickets? | Meaning |
|---|---|---|---|
|
positive |
no |
Money in. |
|
negative |
no |
The money never arrived or was never this much — a mis-key, a returned check, or a chargeback. The order drops out of paid status and its tickets stop being drawing-eligible, but the purchaser still has a live order they can make good on. |
|
negative |
yes — voids every one |
Money returned. Only reachable through the two-person refund control below. |
Methods are exactly CASH, CHECK, MONEY_ORDER, CREDIT_CARD_EXTERNAL — there is no OTHER.
Two dates, not interchangeable. paymentDate is the business date the money moved (a check
received Friday, keyed Monday) and is what a bank deposit is matched against. recordedAt is the
system date from the authoritative clock — when Counterfoil learned — and is the only axis on which
"was this order paid as of instant T" can be answered for drawing-pool determinism. It is never
client-supplied.
Eligibility is order-level. An order’s tickets enter drawing-pool eligibility only when payments
total the full order amount. PARTIAL is not partial eligibility — it is no eligibility.
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Record a payment |
|
|
|
— |
Same |
|
— |
|
|
Correct an entry in full. |
|
|
|
all authenticated roles |
The order’s ledger + derived state |
|
|
|
all authenticated roles |
Search, paged. |
|
|
Recording is open to Sales Operators because cash at an in-person table is taken and recorded by the
operator who keyed the order; requiring a Finance officer per cash sale would push recording out of
the system and into a spreadsheet. Segregation is kept where it matters: reversals are Finance/Admin,
and a refund needs two of them. No payment endpoint grants AUDITOR or SYSTEM_ADMINISTRATOR write
authority.
Replay safety. clientRequestId is required and unique per order
(UNIQUE (order_id, client_request_id)). A retried submit returns 200 with the original entry
rather than recording the money twice. Separately, processorReference is globally unique when
present — recording the same processor transaction twice is a data-entry error the database catches.
Record-payment request
{ "clientRequestId": "b71c…-uuid",
"method": "CREDIT_CARD_EXTERNAL",
"amount": "600.00",
"paymentDate": "2026-07-26",
"processorName": "ORG-MERCHANT-1",
"processorReference": "ch_3PqL8s2eZvKYlo2C1a9bXyZq",
"cardLast4": "4242",
"checkNumber": null,
"note": null }
Response 201 — order.ticketsEligible is stated on every payment response so an operator sees the
consequence of what they just recorded: the moment this purchaser’s tickets can win.
{ "id": 3310, "orderId": 8841, "orderReference": "ORD-8841",
"entryType": "PAYMENT", "method": "CREDIT_CARD_EXTERNAL",
"amount": "600.00", "paymentDate": "2026-07-26",
"recordedAt": "2026-07-26T17:22:04.118Z", "recordedByUserId": 77,
"processorName": "ORG-MERCHANT-1",
"processorReference": "ch_3PqL8s2eZvKYlo2C1a9bXyZq", "cardLast4": "4242",
"order": { "orderId": 8841, "orderReference": "ORD-8841", "orderStatus": "COMPLETED",
"totalAmount": "600.00", "amountPaid": "600.00",
"paymentStatus": "PAID", "paidOn": "2026-07-26",
"ticketsEligible": true } }
The unpaid work queue is a filter on the existing order list, not a new endpoint:
GET /api/v1/raffles/{id}/orders?paymentStatus=UNPAID (or PARTIAL). These are the orders whose
tickets are excluded from every drawing pool until they are paid or voided.
B.13. Refunds (two-person control)
A refund returns money and voids every ticket on the order, in the same transaction. There is no state in which a refund exists against a live ticket: any failure rolls back the whole thing. One person requests; a different Raffle Administrator or Finance/Records Officer approves — the same control Module 4 applies to ticket voids, kept at the level where the human decision actually is.
v1 refunds a whole order. A purchaser returning some tickets is handled as a full refund plus a fresh order; a line-level partial refund would require a mutable order total and an R18 breakout no source document supports. To correct a recording mistake without touching tickets, use a reversal instead.
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Open a request (whole order) |
|
|
|
|
Approve: money back, all tickets void, order closed. Body |
|
|
|
|
Reject; nothing moves. Body |
|
|
|
all authenticated roles |
List, paged ( |
|
|
The request carries no amount — a refund always returns the whole of what the order has paid, re-read
under the order lock at approval time so it cannot go stale against a payment or reversal that lands
in between. REFUND_NOT_ELIGIBLE also covers the case where a ticket on the order has a pending
individual void request: the refund waits for that control rather than racing it.
Approval response 200 names the consequence explicitly:
{ "id": 44, "orderId": 8841, "orderReference": "ORD-8841", "status": "APPROVED",
"requestedByUserId": 77, "requestedByIdentity": "Lee Finance <lee@example.org>",
"approvedByUserId": 12, "approvedByIdentity": "Dana Whitfield <dana@example.org>",
"resolvedAt": "2026-07-27T15:02:55.301Z",
"refundPaymentId": 3318, "amount": "600.00",
"voidedTicketCount": 7,
"voidedDrawNumbers": ["001204","001205","001206","001207","001208","001209","001210"],
"order": { "orderStatus": "VOIDED", "paymentStatus": "REFUNDED", "amountPaid": "0.00" } }
The list carries orderReference on every row and, on an APPROVED row, voidedTicketCount and
voidedDrawNumbers — the same consequence the approval response named, read back from the stored
void rows. refundPaymentId and order appear on the approval response only.
Each ticket voided by a refund gets its own ticket_void_request row carrying reason = REFUND and
the refund’s id, so an auditor sees one decision that voided seven tickets, not seven unexplained
voids. REFUND is not selectable on an individual void request — the only way to reach it is the
refund control.
B.14. Reconciliation & financial summary
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
The reconciliation report; |
|
|
all authenticated roles |
Gross receipts to date, prize exposure, provisional net |
|
Errors: 400 VALIDATION_ERROR, 404 NOT_FOUND on both.
basis is PAYMENT_DATE (default — the business axis, for deposit matching) or RECORDED_AT (the
system axis, for audit replay). It governs both sides of the report, because the balancing identity
only holds when both are measured on the same axis. Generating a report emits REPORT_GENERATED.
Two views, two sources, deliberately. byMethod / byChannel / byEntryType are every ledger
entry in the range — the figure a deposit is matched against. grossReceiptsByPlanOption is the R18
discounted-sales breakout, taken from each order’s immutable line snapshot and recognized when an
order becomes fully paid (never prorated across installments — splitting a partial payment across a
mixed single-plus-bundle order would invent a per-plan figure no source document supports).
heldAgainstPartialOrders is the real quantity that ties them together: money received against
orders that are not yet fully paid, and therefore not yet gross gambling receipts.
An order paid in two installments across a period boundary shows as held in the first period and recognized in the second, and both periods balance:
Σ byMethod == Σ byChannel == Σ byEntryType == ledgerTotal
ledgerTotal == grossReceiptsRecognized + heldPartialMovement
A report that does not balance is never returned with a warning flag — it fails with the discrepancy
named, matching Module 12’s "any mismatch blocks generation" posture. contentHash covers the figures
only (not the generation timestamp or user), so regenerating from unchanged records yields an
identical hash.
financial-summary reports gross receipts to date, the prize exposure configured in Module 3, and
netReceiptsProvisional. That net is legitimately and deeply negative early in a raffle, because a
$1M grand prize is configured long before receipts accumulate; the response carries notes stating
that prizes paid, the cash/noncash split and the R11 reserve check are Module 11’s, and the tax
worksheet is Module 12’s.
B.14.1. Events
Recording an entry emits RECORD_CREATE (entity_type = payments); the resulting order transition
emits RECORD_MODIFY (entity_type = raffle_orders) with before/after status and
ticketsEligible — the searchable record of the instant a purchaser’s tickets became able to win.
Refund requests emit RECORD_CREATE / RECORD_MODIFY (entity_type = refund_requests), and each
ticket a refund voids emits Module 4’s unchanged RECORD_VOID. The processor reference and
cardLast4 are included in summaries — they are the audit link to the merchant record and neither
is cardholder data — while the purchaser’s name is masked and no address, full phone or email appears.
A refused payment emits the new PAYMENT_REJECTED type, committed independently so it survives the
rejected transaction’s rollback. summary.reason is one of CARD_DATA_DETECTED,
EXCEEDS_ORDER_TOTAL, ORDER_NOT_PAYABLE, DUPLICATE_PROCESSOR_REFERENCE.
CARD_DATA_DETECTED warrants an alert on any occurrence — someone attempted to put a card number
into the gaming system.
B.15. Receipts & confirmations
Every fully paid order gets a receipt carrying the disclosures RCW 9.46.0323(3)(j) (R9) and WAC 230-11-010/-360 (R10) require: organization name and license number, raffle name, purchaser name, order reference, every draw number issued, the pricing-plan option and amount paid, each drawing’s date/time/location, whether the winner must be present, and the prize descriptions with any disclosed minimum.
A receipt is an immutable snapshot, not a query. The disclosures are resolved and frozen at
issuance and stored as content_json; the PDF is rendered from that snapshot on demand and never
stored. Two things follow. Re-sending never changes what a purchaser was told, even after their
address is corrected or the local tax rate is edited. And the render is byte-deterministic — the
document’s dates and /ID come from the receipt’s own issuance instant and content hash rather than
the wall clock — so a reprint three years later is provably the same document.
A correction is a reissue: a new version supersedes the prior one, which is retained. The draw numbers cannot change; they are re-read from the same tickets.
Issuance is automatic. A server-side sweeper issues a receipt for every fully paid order, so no operator has to remember. The endpoint below is the on-demand path and is idempotent.
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Issue the receipt (idempotent) |
|
|
|
all authenticated roles |
The receipt in force for an order |
|
|
|
all authenticated roles |
Search / the delivery work queue (paged) |
|
|
|
all authenticated roles |
One receipt, any version |
|
|
|
all authenticated roles |
Render the snapshot ( |
|
|
|
|
Queue a delivery |
|
|
|
|
Supersede with a fresh snapshot |
|
|
deliver is AUTO (default — email when the purchaser has a usable address, otherwise mail) or
NONE (issue the record and queue nothing; the first-enable backfill mode on a raffle that has been
selling for months).
GET …/document marks nothing. Rendering bytes is not evidence that a purchaser was sent
anything — a receipt becomes "printed for mail" only when an operator confirms a print run.
B.15.1. Delivery
channel is exactly EMAIL or MAIL_PRINT, the two channels R9 authorizes. There is no SMS path.
Email goes through a transactional provider via a transactional outbox: issuance records a queued attempt, and a scheduled dispatcher calls the provider with no database transaction open. So a provider outage delays receipts and breaks nothing, and a deployment with no provider at all is complete — every receipt simply queues for mail, which is fully lawful.
Delivery is at-least-once. Providers offer no idempotency key, and given the choice between a purchaser occasionally receiving two identical receipts and occasionally receiving none, R9 settles it. Both attempts are recorded.
Bounces are polled, not received by webhook — no inbound endpoint exists on this system. A hard
bounce flips the delivery to BOUNCED, queues a mail fallback, and suppresses that address until it
is corrected. emailedAt is left as it was: the send did happen, and the bounce is a second fact.
deliveryState on a receipt is a cache of its latest attempt (UNDELIVERED, EMAIL_QUEUED,
EMAIL_SENT, BOUNCED, MAIL_PENDING, MAILED, FAILED) and is what the work-queue filter reads.
toEmail is returned in full to FINANCE_RECORDS_OFFICER and RAFFLE_ADMINISTRATOR — who are the
ones who correct it — and masked for every other role, auditors included.
B.16. Print batches
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Open a run over everything waiting for the post, or return the open one |
|
|
|
all authenticated roles |
List runs (paged) |
|
|
|
all authenticated roles |
One run and its receipts |
|
|
|
all authenticated roles |
The batch PDF ( |
|
|
|
|
It printed — mark every member mailed |
|
|
|
|
It jammed — requeue every member |
|
|
Three separate acts, deliberately. Generate gathers what is waiting and renders it. Fetch streams the PDF as often as you like and marks nothing. Confirm is the one thing that records a purchaser as mailed, with the confirming user and the time — because a record saying a receipt was sent must mean a human saw it come off the printer. An abandon requires a reason and returns every member to the queue as a fresh attempt; a run left unconfirmed past its expiry is abandoned automatically, so a forgotten batch cannot strand receipts.
The batch PDF is one multi-page document with each receipt beginning on a fresh page, so a run can be
split at page boundaries and never puts two purchasers on one sheet. pageCount is the sheet count to
check the paper stack against, and contentHash covers the ordered member receipt hashes.
B.17. Counterfoils & print management
The physical drawing pool: the stubs a winner is hand-drawn from, and the record that proves the barrel is complete (R2, R10, R12, R13, R17). Defined by the counterfoil module spec.
A batch is a materialized, immutable set — not a query. Generating one resolves the drawing’s
eligible pool at a single instant (poolAsOf) and writes one row per stub, freezing the purchaser
name, city, state and phone that print on it. Everything downstream — rendering, the manifest, the
checksum, a reprint after a jam, Module 12’s archive — reads those frozen rows and never re-derives
the set. It has to work this way because the inputs move: a purchaser can be corrected, a ticket
voided, a payment reversed. The barrel is a snapshot of a moment, so its record is one too.
This module does not compute pools. Section B.18 owns the eligibility rules and
supplies membership through a contract this module only consumes. A batch cut against a drawing whose
pool is still open records poolBasis: PRELIMINARY; once the pool is closed it records
poolBasis: CLOSED with that pool’s poolId, so an auditor can tell from the batch row alone whether
it was cut against a final pool or a moving one.
B.17.1. Batches (generate: RAFFLE_ADMINISTRATOR · read: all authenticated roles)
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Cut a barrel ( |
|
|
|
all authenticated roles |
Batches for one drawing (paged) |
|
|
|
all authenticated roles |
Every batch in the raffle (paged) |
|
|
|
all authenticated roles |
One batch and its print coverage |
|
|
|
all authenticated roles |
The frozen stubs (paged) |
|
|
|
all authenticated roles |
The Counterfoil Manifest (R17) |
|
|
|
|
Alignment proof — the first few stubs, capped server-side |
|
|
mode semantics: INITIAL refuses when the drawing already has an active batch — a second full
barrel by accident is a duplicate stub for every ticket. SUPPLEMENT takes the eligible pool
minus every member of every active batch, which is what makes "the union of manifests equals the
eligible pool" true by construction. REPLACEMENT supersedes every active batch in the same
transaction, reprints the whole pool, and requires a reason.
A superseded batch and all its stubs are retained forever. The preview cap is the control that stops a preview becoming a print run with no record.
B.17.2. Print runs (open/stream/resolve: RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER · read: all)
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Open a run over |
|
|
|
all authenticated roles |
Run history |
|
|
|
all authenticated roles |
One run |
|
|
|
|
The run’s device bytes |
|
|
|
|
It came out clean |
|
|
|
|
It jammed — |
|
|
Three separate acts, deliberately. Open declares intent and creates the record that survives a crash mid-print. Stream can be repeated as often as you like and marks nothing — bytes reaching a socket say nothing about paper. Confirm is the one act that puts stubs in the barrel as far as the system is concerned, with the confirming user and the time, because a record saying twelve thousand stubs are in the barrel must mean a human watched them come out. ESC/POS status reporting is optional, vendor-divergent and buffered; the operator’s eyes are the only reliable print sensor.
Runs cover contiguous sequence ranges, so a roll that binds at stub 8,412 of 20,000 costs one resume
run rather than a reprint of the first 8,411. A batch is printed when the union of confirmed ranges
covers it; overlapping ranges union, since reprinting a smeared hundred is a normal act.
printCoverage is computed from the runs on every read and cached nowhere — it is the one fact here a
human certifies. An abandon leaves coverage untouched, so the gap stays visible; a run left open past
its expiry is auto-abandoned, because a forgotten run must not leave a batch looking half-printed or
printed.
A confirmed run with a null streamHash is meaningful, not missing data: it marks a range accepted
off a partial stack rather than driven through a device in that run. confirmationMode distinguishes
a human answering a prompt (INTERACTIVE) from the CLI’s --yes (NON_INTERACTIVE); SYSTEM
belongs to the expiry sweeper and is rejected from a client.
Render profiles are ESCPOS_80 (the reference — an 80 mm thermal receipt printer, Code 128 with
human-readable text below), ESCPOS_58 (narrow stock; QR instead, because Code 128 at this payload is
not reliably scannable across a 384-dot head), and PDF_LETTER / PDF_A4 (4-up sheet stock with cut
guides — the right tool for a full 20,000-stub grand-prize barrel, which is ~800 m of thermal paper
and ~90 minutes otherwise). All three print the identical facts and the identical machine-readable
payload CF1:<raffleId>:<drawingId>:<drawNumber>, so Module 10 accepts a scan from any of them.
Rendering is a pure function of the frozen rows and the profile — no clock, no randomness — so two
renders a restart apart produce identical bytes.
The server renders; the workstation prints. There is no printer driver, no spooler and no stored document anywhere in this system.
B.17.3. Verification (read-only, all authenticated roles)
| Method & path | Purpose |
|---|---|
|
Compare the union of active manifests to the pool now |
Success 200 OK; errors 400 FEATURE_NOT_AVAILABLE, 404 NOT_FOUND.
An unbalanced result is a 200 carrying both difference sets, never an error — a screen has to be
able to show which stubs are wrong. inPoolNotPrinted is normal while sales continue and is what a
supplement batch is for. printedNotInPool is not normal: a ticket was voided or its payment
reversed after its stub was printed, so there is a physical stub in the barrel that must be pulled
before the drawing. Difference lists truncate at a configured cap with a truncated flag, because an
unbalanced grand-prize drawing can differ by thousands.
readyForDrawing is the single predicate Module 10 will consult: balanced ∧ every active batch fully
printed ∧ the drawing’s pool closed. The blockers list names whichever of those is outstanding.
B.17.4. Events
Generating a batch emits RECORD_CREATE (entity_type = counterfoil_batches); a regeneration adds
RECORD_MODIFY on each superseded batch. Opening a run emits RECORD_CREATE and
confirming/abandoning/expiring it emits RECORD_MODIFY (entity_type = counterfoil_print_runs).
Manifests and verifications emit REPORT_GENERATED.
No new event type. A jammed run is not a delivery obligation like R9 — it is an operational event
with a required reason, fully recovered by the next run, and "which runs jammed and why" is one
entity_type filter away. An unbalanced verification is deliberately a REPORT_GENERATED carrying
balanced: false: it is a report, and inventing a type for "a report said no" ends with a type per
assertion.
Event summaries carry counts and hashes only — no purchaser name, city, phone or draw-number-to-person mapping. Twenty thousand names in an append-only, hash-chained, un-deletable log is a retention defect no role could ever undo; the identities live in the access-controlled stub rows, which Module 12 archives per raffle.
B.18. Drawing pools & eligibility
Which tickets are in which drawing — the most legally sensitive logic in the system (R5, R6). Defined by the drawing pools module spec.
A pool is a pure function of (locked configuration, asOf). Membership recomputes from immutable
inputs only: the locked drawing’s typed parameters, placedAt, issuedAt/voidedAt, and Module 6’s
as-of ledger form — never paymentStatus, which is a cache. Given the same instant it returns the
same set today, on drawing night, and in the 2029 audit.
Closure freezes that output, once, irreversibly. There is no reopen, no re-close and no supersession; "open" is simply the absence of a closure. Closing is refused before the drawing’s entry cutoff has passed, which is what makes irreversibility survivable — the failure it would otherwise expose, a pool closed before the sales it was meant to contain, cannot happen.
Two instants, never interchangeable. The entry cutoff bounds the sale (placedAt ≤ cutoff) and
comes from locked configuration — for a grand prize drawing it is structurally the raffle’s sales
close. The closing instant bounds what the system knew: void and payment state are evaluated
there. A mail order placed before an early bird cutoff whose cheque clears afterwards is in the pool
if the administrator closes once the mail is processed, and out if they close at the stroke of the
cutoff. That choice is theirs, and pendingPaymentCount tells them what it costs before they make it.
Membership and standing are different questions. Membership is frozen and can only shrink after
closure, never grow — no sale and no late payment can join a closed pool. Standing — can this stub
still win — is computed live, so a ticket voided or a payment reversed after closure leaves the
drawable set immediately (R13) with no write anywhere. Those members stay in the frozen list, named in
withdrawn with a reason, because the stub is physically in the barrel and someone has to pull it.
Rules per drawing type, dispatched from a registry so adding one touches no existing rule:
| Rule | Type | Members |
|---|---|---|
|
|
Every paid, non-void ticket ordered on or before the raffle’s sales close |
|
|
The same, bounded by the drawing’s own cutoff. Winning removes nothing — no rule takes a winner as input (R6) |
|
|
Every ticket of every purchaser holding at least |
— |
|
No rule registered → |
ruleId is versioned and frozen onto every closed pool, so a pool closed under a superseded rule
stays traceable and the determinism check can distinguish "not reproducible under this build" from
"corrupt".
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
Every drawing’s pool state — the drawing-prep view |
|
|
all authenticated roles |
One pool: rule narrative, counts, and (closed) the withdrawn list |
|
|
all authenticated roles |
Members, each with its standing |
|
|
|
Close it — irreversible. |
|
|
all authenticated roles |
Recompute at the closing instant and compare |
|
Errors: GET /pool and /members — 400 FEATURE_NOT_AVAILABLE, 400 RULE_VIOLATION,
404 NOT_FOUND; closure — 409 POOL_ALREADY_CLOSED, 409 POOL_CUTOFF_NOT_REACHED,
409 POOL_PENDING_PAYMENTS, 400 FEATURE_NOT_AVAILABLE, 400 RULE_VIOLATION, 404 NOT_FOUND;
verification — 409 POOL_NOT_CLOSED, 404 NOT_FOUND.
Closure takes { "acknowledgePendingPayments": bool, "note": "…" }. There is no field that could
select a different member set: the members are whatever the locked rule computes at the closing
instant. When orders placed by the cutoff have not fully paid, closing is refused with
409 POOL_PENDING_PAYMENTS naming the count until the acknowledgement is sent — the mail-in-transit
fail-safe. An empty pool is allowed to close: a drawing with no paid entrants by its cutoff is a
real situation, and refusing would leave it permanently un-closeable and therefore un-holdable.
/pool/verification is the determinism guarantee as a live endpoint rather than a claim in a test
report: it re-runs the rule at the pool’s own closedAt and compares both the set and the hash to
what was frozen. It never repairs — a mismatch means something that was supposed to be immutable
moved, and rewriting the snapshot to agree would destroy the only evidence of it. deterministic: null
(rather than false) means the pool was closed under a rule version this build no longer carries,
which is not reproducible by design. The same check runs nightly across every closed pool.
B.18.1. Events
Closing a pool emits RECORD_CREATE (entity_type = drawing_pools) — the row’s existence is the
closure, so there is no prior state to modify. The determinism check emits REPORT_GENERATED.
No new event type, and DRAWING_OPENED/DRAWING_CLOSED are deliberately left unspent: they bracket
the Module 10 drawing session, where the pair reads in chronological order, and an event type name is
a persisted identifier that is never renamed. As in Module 8, summaries carry counts and hashes
only — no purchaser name or draw-number-to-person mapping.
B.19. Drawing execution & winner recording
Conducting the physical drawing (R2, R11, R12, R17). Defined by the drawing execution module spec.
A draw event cannot exist without a human’s input string. Every row carries a NOT NULL
entryMethod (SCAN or KEYED) and a NOT NULL rawInput — the literal characters the scanner or
keyboard produced. There is no constructor, no endpoint and no code path that creates a draw event
from a pool query, and the database would refuse one. RCW 9.46.0323 forbids drawing by RNG or similar
means; this is R2 enforced by schema rather than by review.
The barrel gate is not overridable; only the reserve gate is. A session opens only when the pool is closed and non-empty, every eligible member has a printed stub, every batch is confirmed, every printed stub that has since lost standing has been individually attested as pulled, and a prize is configured. Each of those means the barrel does not match the record, and no authority makes it match. The R11 reserve check is overridable, by an explicitly logged decision with a reason — the reserve is a fact about a bank account this system can only ever have second-hand.
The reserve check is real as of Module 11, and PASSED is reachable once a balance is on file. It
answers NOT_AVAILABLE when it cannot be performed — no balance recorded, a balance older than the
freshness window, or a prize with no record and therefore no cash/noncash classification — and
FAILED only when it was performed and the funds are short. Either way the session may still open,
with an override reason of at least the configured minimum length; the figures, the status and the
reason are frozen onto the session and printed in the report. Two Module 11 preconditions are not
overridable and join the barrel blockers: PRIZE_RECORD_REQUIRED and
PRIZE_DOCUMENTATION_REQUIRED, plus ALL_PRIZES_AWARDED when every prize on the drawing already has
an unforfeited winner. Unlike a bank balance, each is a fact about this database with a thirty-second
remedy — see Section B.20.
Recording a winner is two people at one keyboard. The Raffle Administrator submits the decision and
a second authorized officer’s credentials are verified in-band, in the same request — no token
issued, no session created, and subject to the same lockout and per-IP rate limit as a login.
Witnessing requires RAFFLE_ADMINISTRATOR or FINANCE_RECORDS_OFFICER and a different user from the
requester; every failure answers one code, WITNESS_INVALID, so an attacker at the drawing table
learns nothing about which. Disqualification is deliberately single-actor: it leads straight to a
redraw, so the remedy is the next line of the report, whereas a recorded winner is terminal.
B.19.1. Sessions
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
all authenticated roles |
The gate, previewed: blockers, pool, manifests, reserve, stubs needing attestation |
|
|
|
|
Open a session; freezes everything |
|
|
|
all authenticated roles |
Sessions for a drawing |
|
|
|
all authenticated roles |
One session: frozen snapshot, live counts, outstanding prizes |
|
|
|
|
Close and generate the report. |
|
|
|
|
Abandon — |
|
|
Opening takes
{ "acknowledgedWithdrawnDrawNumbers": […], "reserveOverrideReason": "…", "notes": "…" }. The
attestation must equal the computed withdrawn-and-printed set — a blanket acknowledgement is not
available, an extra draw number is a 400 naming it, and a missing one is
409 WITHDRAWN_STUB_UNACKNOWLEDGED naming it. The attested list is frozen onto the session and
printed in the report, so "which stubs were removed from the barrel before this drawing, and who said
so" is answerable from the R17 artefact alone.
More than one session per drawing is permitted — a session opened in error and stood down before any
winner leaves a record and the next starts fresh — but only one may be OPEN at a time, enforced
under the drawing row lock. Abandoning is refused once a winner exists.
B.19.2. Draw events
| Method & path | Roles | Purpose | Success | Errors |
|---|---|---|---|---|
|
|
Present a stub: |
|
|
|
|
Confirm the winner: |
|
|
|
|
Set aside: |
|
|
|
all authenticated roles |
The ordered sequence |
|
|
A rejected presentation is a 201, not a 4xx. The refusal has to be logged, and the row is the
log — it carries its sequence position so the report shows it in the order it happened. Returning an
error status would put the evidence of an attempted bad draw in an application log instead of in the
record. Rejection reasons are MALFORMED, WRONG_RAFFLE, WRONG_DRAWING, NOT_IN_POOL,
WITHDRAWN (a pool member that lost standing — the stub should have been pulled) and ALREADY_DRAWN
(a disqualified stub is set aside, not returned to the barrel).
An accepted presentation returns the purchaser’s name, city, state and phone, plus how many of their
stubs are in this barrel and which prizes are still unawarded. This is the one place in the product
where a full name and phone are deliberately put on screen at speed — read-only, role-gated, and
written to no event summary. A scan resolves either a full
CF1:<raffleId>:<drawingId>:<drawNumber> payload from any render profile, or a bare draw number keyed
by hand.
B.19.3. The report
| Method & path | Purpose |
|---|---|
|
The immutable Raffle Drawing Report (R17) |
Success 200 OK; errors 404 NOT_FOUND (the session is not closed), 400 VALIDATION_ERROR. Roles:
all authenticated.
Generated atomically with the close: there is no closed session without a report and no report
without a closed session. There is no regenerate endpoint — a report is fetched, never rebuilt,
because rebuilding is where "immutable" quietly stops being true. content_json is the record; the
PDF and CSV renderers are pure functions of it, with dates and /ID from the report’s own generation
instant and content hash, so two fetches years apart return identical bytes. It carries the drawing’s
identity, the pool with its plain-English eligibility standard, the counterfoil manifests and their
checksums (Module 8 AC #6), the attested barrel removals, the reserve position and any override,
every draw event in order including the refusals and disqualifications, the winners register, and
the officiating users.
B.19.4. Events
Opening a session emits DRAWING_OPENED and closing it emits DRAWING_CLOSED (entity_type =
drawing_sessions); abandoning emits RECORD_MODIFY. Presenting a stub — accepted or rejected —
emits RECORD_CREATE on draw_events; confirming a winner emits WINNER_RECORDED; disqualifying
emits RECORD_MODIFY. The report emits REPORT_GENERATED.
No new event type, in this module or Module 9. DRAWING_OPENED/DRAWING_CLOSED have been in the
catalog since Module 2 and bracket the session in chronological order, which is why Module 9’s pool
closure is a RECORD_CREATE instead — the allocation was decided across both specs at once, because
event type names are persisted identifiers that are never renamed. A rejected stub earns no dedicated
type because it leaves a row; the test the catalog applies is whether the fact survives without one.
A winner’s name is not in the event summary. WINNER_RECORDED carries the draw number, the prize id
and the two officiating user ids — enough to find the record, not enough to make an append-only log a
permanent PII store. A winner is exactly the person a licensee may later be asked to redact from
something.
B.20. Prizes, reserve tracking & winner fulfilment
Prize evidence, the R11 unencumbered-funds check, and award completion (R10, R11, R12). Defined by the prizes, reserve tracking & winner fulfilment module spec.
Cash versus noncash is a Module 11 fact, and that is why the prize-record gate is a necessity rather than a formality. Module 3 stores a prize’s disclosure — description, value, fixed or percentage — and has never needed to know whether satisfying it takes money out of the bank or a car off a lot. R11 needs exactly that, and nothing else in the product supplies it. So without a prize record for every configured prize, the reserve check cannot be performed at all, which is why "no prize record, no drawing session" is a hard precondition rather than paperwork.
A prize record is evidence, not a term of the raffle, which is why it lives outside Module 3’s
pre-sale lock: a dealership signs in November for a December drawing, and requiring the paper before
the first ticket is sold would make the lock unsatisfiable. It is constrained in the one way that
matters — retailValue must equal the value Module 3 disclosed (400 PRIZE_VALUE_MISMATCH), so the
evidence layer may describe a prize but never restate what was advertised (R10). It then acquires its
own lock: once a drawing session has opened for its drawing, the record is frozen
(409 PRIZE_RECORD_LOCKED), because that session was gated on this evidence and froze a reserve
position computed from it.
NOT_AVAILABLE and FAILED are different answers and the difference is load-bearing. FAILED says
the WAC 230-11-065 check was performed and the funds are short. NOT_AVAILABLE says it could not be
performed — no balance on file, a balance older than the freshness window, a prize with no record, or
a percentage-of-receipts prize this version has no reserve valuation rule for. Reporting any of those
as FAILED would write a false claim about a licensee’s bank account into an immutable R17 artefact;
reporting them as PASSED would be worse.
The obligation spans the drawing’s date, not the drawing. If an early bird and the grand prize are drawn the same evening — which is how these events are run — then $1.2M on deposit does not cover $1M plus $500k. Every cash prize on every active drawing sharing the local calendar date (in the raffle’s configured time zone) is summed, and prizes already awarded are not excluded: the conservative reading is the whole day’s obligation, and excluding them would let a genuine shortfall pass on a redraw.
Role posture. Every write here is FINANCE_RECORDS_OFFICER alone — a deliberate segregation,
because the officer who configured and advertised a prize (Module 3) and conducts the drawing
(Module 10) should not be the officer who attests the prize exists and was delivered. Every
authenticated role may read, including AUDITOR.
B.20.1. Prize records
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
|
File evidence: |
|
|
|
Correct type / value / document fields / supplier / notes |
|
|
all authenticated roles |
The evidence on file |
|
|
all authenticated roles |
Configured prizes with no record — the worklist |
|
|
all authenticated roles |
One record |
|
Errors: create — 400 VALIDATION_ERROR, 400 PRIZE_VALUE_MISMATCH, 400 CARD_DATA_REJECTED,
400 FEATURE_NOT_AVAILABLE (percentage prize), 404 NOT_FOUND, 409 PRIZE_RECORD_EXISTS,
409 PRIZE_RECORD_LOCKED; correct — as above, plus 409 PRIZE_RECORD_LOCKED; reads —
404 NOT_FOUND.
prizeType is CASH or NONCASH; supportingDocumentType is PURCHASE_CONTRACT,
AVAILABILITY_EVIDENCE, DEPOSIT_EVIDENCE or OTHER. A NONCASH prize valued above the configured
documentation threshold must carry supportingDocumentRef before a session can open — that is R11’s
second half, demonstrable availability, and a cash prize above the threshold is unaffected because the
bank balance is its evidence. Every operator-supplied text field passes the PanGuard check; a
rejected value is never echoed back. There is no DELETE.
B.20.2. Reserve balances
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
|
Record one observation: |
|
|
all authenticated roles |
The observation log, newest first |
|
|
all authenticated roles |
The R11 position now, with the full breakdown |
|
Errors: 400 VALIDATION_ERROR, 400 CARD_DATA_REJECTED, 404 NOT_FOUND.
reserve_balances is append-only: there is no PATCH and no DELETE, by design. A mis-keyed
figure is corrected by recording the correct one, so the record shows what was recorded and later
corrected. Unlike payments there is no reversal entry type, and the asymmetry is deliberate: a
payment accumulates so a correction must subtract and SUM(amount) is the truth; a balance is an
observation of a level, so a correction supersedes and the latest observation as of a date is the
truth. Summing this column would be meaningless.
Two dates, never interchangeable (Module 6’s rule): asOfDate is the business date the statement
speaks to and is the freshness axis; recordedAt is the Clock instant the system learned and is the
tie-break and audit-replay axis. asOfDate may not be in the future, and backdating beyond
counterfoil.prizes.max-balance-backdating-days is refused.
Reading the check records nothing. PRD AC #2 requires the result, inputs and recording user logged,
and they are — at the two moments the check is relied upon: recording an observation
(RECORD_CREATE carrying the figures) and opening a drawing session (DRAWING_OPENED, which freezes
the whole position). An event per GET would let anyone with a loop inflate a hash-chained log and
would bury the two entries that matter.
The check response carries the status, the obligation with a per-prize breakdown of anything that could not be valued, the observation behind it with its age in days, and a note an operator can act on:
{ "drawingId": 19, "raffleId": 7, "asOf": "2026-12-30T19:00:00.000Z",
"check": { "status": "NOT_AVAILABLE", "cashPrizeTotal": null,
"recordedBalance": "1240000.00", "balanceSourceDocument": "BofA statement 8841-2",
"note": "1 configured prize(s) cannot be valued, so the cash obligation for 2026-12-31 cannot be stated: prize 44 (Early Bird 3 — $25,000 cash) — no prize record on file, so it cannot be classified as cash or noncash" },
"obligation": { "known": false, "cashPrizeTotal": null, "noncashPrizeTotal": "0.00",
"drawingDate": "2026-12-31", "drawingIds": [19, 22],
"gaps": [ { "drawingPrizeId": 44, "drawingId": 22, "reason": "NO_PRIZE_RECORD",
"description": "Early Bird 3 — $25,000 cash" } ] },
"observation": { "balanceId": 3, "balanceAmount": "1240000.00", "asOfDate": "2026-12-29",
"sourceDocument": "BofA statement 8841-2" },
"observationAgeDays": 1, "observationStale": false, "maxObservationAgeDays": 7 }
The freshness window is measured from the moment of the check, not the drawing’s date, so a
readiness preview a month out with a same-day balance is green while the check at session open still
demands a recent statement. It is a code constant (ReserveLimits.BALANCE_MAX_AGE_DAYS), not
configuration, for the same reason the statutory limits are constants: whether a WAC check can be
considered performed is a compliance question, and a deployment that could widen it in a YAML file
would have a check that means whatever the operator needs it to mean.
B.20.3. Winners & fulfilment
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
Winners with fulfilment status |
|
|
all authenticated roles |
One winner and their fulfilment |
|
|
|
Record |
|
|
|
Delivered: |
|
|
|
Not claimed: |
|
Errors: list — 400 VALIDATION_ERROR; fetch — 404 NOT_FOUND; the three mutations —
400 VALIDATION_ERROR (PATCH/fulfilment also 400 CARD_DATA_REJECTED), 409 FULFILMENT_NOT_PENDING.
A fulfilment record is created by Module 10’s award, in the same transaction, so there is no winner
without one and no fulfilment record without a winner. Status is PENDING, AWARDED or
FORFEITED_REDRAWN, forward-only from PENDING, and both other states are terminal — a delivered
prize is not un-delivered through a WSGC record. No request body carries a status field: a transition
is an explicit act on its own endpoint. There is no DELETE.
Forfeiture is the interlock that makes a redraw legal. Module 10 already enforces one winner per
prize per session; a stored generated column that nulls itself on forfeiture, under a UNIQUE
index, enforces at most one unforfeited winner per prize across sessions. So forfeit-before-redraw
is the only order the database will accept: before the forfeiture, POST …/sessions refuses with
409 ALL_PRIZES_AWARDED and a second award fails on the key (409 PRIZE_ALREADY_FULFILLED); after
it, the prize is released and a new session can be opened, drawn and awarded. Forfeiting is
deliberately single-actor with a required reason, on the same reasoning Module 10 applies to
disqualification: it awards nothing to anybody, and the remedy is a redraw which is itself two-person
at the award.
The delivery endpoint is /fulfilment rather than /award because "award" already means recording
the winner at the drawing table in this product, and two endpoints of that name meaning different
things is how an operator ends up in the wrong one.
B.20.4. The prize-and-winner register
| Method & path | Purpose |
|---|---|
|
The register WSGC recordkeeping requires (R12) |
Success 200 OK; errors 400 VALIDATION_ERROR, 404 NOT_FOUND. Roles: all authenticated.
Every configured prize with its evidence, every recorded winner with name, mailing address, draw number, prize, value and award date, and the current reserve position per drawing date.
A live view, and it gets the guarantee that goes with that. A receipt, a counterfoil batch and a Raffle Drawing Report are frozen artefacts whose bytes are reproducible forever. This register’s whole job is to track fulfilment as it progresses, so freezing it would make it a second, staler drawing report. What is reproducible here is the content hash, computed over the figures alone and excluding the generation instant and user — an unchanged hash means unchanged records rather than an unchanged clock, and recording one delivery changes it. The winner’s address is read live, because a fulfilment officer posting a cheque wants the current one; the address given on drawing night is frozen in the Raffle Drawing Report, which each entry’s session and sequence number identify.
B.20.5. Events
Filing a prize record emits RECORD_CREATE and correcting one emits RECORD_MODIFY (entity_type =
prize_records). Recording a balance emits RECORD_CREATE on reserve_balances. Delivering or
forfeiting a prize emits RECORD_MODIFY on winner_fulfilments. Fetching the register in any format
emits EXPORT_PRODUCED carrying the content hash and the counts.
No new event type. The fulfilment row created at award emits nothing of its own either: Module 10
already emits WINNER_RECORDED in that transaction, and a winner was recorded is one fact — a
second event describing the projection of the first would be noise in a log an auditor reads linearly.
No winner’s name reaches any summary, extending Module 10’s rule to the module most tempted to break
it.
B.21. Organization
| Method & path | Purpose |
|---|---|
|
The licensee in force, its hash, and whether the server may sell |
Success 200 OK. Roles: all authenticated.
There is no write endpoint, by design. A Counterfoil server serves exactly one legal entity for its
whole life, so the licensee is a JSON file (COUNTERFOIL_ORGANIZATION_FILE) read once at startup,
validated and hashed. Changing it is an edit plus a restart. That keeps licensee identity out of the
application’s gaming-record write surface entirely, and its history lives in the append-only,
hash-chained event log where no role can edit it.
The hard rule: with no organization file, no raffle can ever start selling. A receipt with no
organization name is not a lawful receipt, so rather than degrade quietly the missing profile blocks
the first ticket issuance — which blocks the pre-sale lock, which means the raffle never opens. The
refusal is 409 ORGANIZATION_NOT_CONFIGURED on order placement, recorded as an ORDER_REJECTED
event, and no draw number is consumed. The pre-lock compliance checklist carries an ORGANIZATION
line item so this is discovered days before sales day rather than at the counter.
When unconfigured this endpoint answers 200 with configured: false plus the reason and its
consequence, rather than 404: an admin screen has to be able to say why nothing can be sold.
At the pre-sale lock the profile in force is frozen onto the raffle, so a later edit to the file can never retroactively change what a live raffle’s receipts say — the move Module 3 already makes with tax-jurisdiction facts.
B.21.1. Events
Issuing a receipt emits RECORD_CREATE (entity_type = receipts); a reissue emits that plus
RECORD_MODIFY on the superseded version, with supersedesReceiptId linking them. Delivery attempts
emit RECORD_CREATE / RECORD_MODIFY (entity_type = receipt_deliveries), and print runs the same
(entity_type = receipt_print_batches).
A receipt that could not be delivered — a hard bounce, an exhausted retry chain, or an abandoned print
run — emits the new RECEIPT_DELIVERY_FAILED type. R9 is an obligation to send a receipt, so
"which ones we could not deliver, and why" must be answerable from one event-type filter.
Loading or changing the organization file emits CONFIGURATION_CHANGE (entity_type = organization,
entity id 1) carrying the before/after hash and the changed field names. Summaries mask the
purchaser’s name and email; no postal address, full phone or provider token ever appears.
B.22. WSGC record set
The standard raffle record set (R12, R17, R18) — every figure a completed raffle has to account for, assembled in one document.
Composed, never calculated. Each section is fetched from the module that owns it: gross receipts and the R18 breakout from Module 6, ticket counts from Module 4, pools from Module 9, batches from Module 8, drawing results from Module 10, winners from Module 11. Frozen artefacts appear as identity and content hash and are never rebuilt.
A live composite, so it gets content-hash determinism: the hash covers the figures and excludes the generation instant and user, so regenerating from unchanged data yields an identical hash. (The finalized tax worksheet is the frozen artefact here, and gets byte determinism instead.)
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
The record set |
|
|
all authenticated roles |
The ten identities and their verdicts |
|
Errors: 404 NOT_FOUND; the record set additionally 409 RECORD_SET_NOT_RECONCILED.
The reconciliation endpoint answers whether or not generation is blocked. A gate whose refusal is
visible only as an error is one people learn to route around; one that names the order whose cached
balance disagrees with its ledger is one they use. The 409 carries the same list in
details.discrepancies.
The ten identities: the R18 breakout sums to gross; gross − prizesPaid = net; per series
unissued + issued + voided = total; paid tickets agree between orders and live tickets; every
order’s cached amount_paid equals its payment ledger; every closed session has exactly one drawing
report; recorded winners agree with the register; every referenced pool is closed and not reported
non-deterministic; every prize record’s retail value equals its disclosed value; every active
counterfoil batch carries a content hash and the batches for a drawing are disjoint.
B.23. Local gambling tax worksheet
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
The finalized worksheet, or a live preview marked |
|
|
|
|
|
Errors: fetch — 404 NOT_FOUND; finalize — 409 RAFFLE_NOT_COMPLETE,
409 RECORD_SET_NOT_RECONCILED, 409 TAX_WORKSHEET_FINALIZED, 409 EXEMPTION_ALREADY_CLAIMED,
409 CUSTODIAN_NOT_CONFIGURED, 400 RULE_VIOLATION, 400 VALIDATION_ERROR (no note),
400 CARD_DATA_REJECTED.
The finalization note is beside the filing, not in it. The JSON response is
{ content, contentHash, finalizationNote }. finalizationNote is omitted for a live preview and for
a worksheet finalized before the note was required. It is not part of content or contentHash, and
the PDF and the report-catalogue exports do not carry it, so a frozen worksheet’s documents never
change.
taxableNet = max(grossGamblingReceipts − prizesPaid − 10,000.00, 0);
taxDue = taxableNet × effectiveRate, capped at 5% (R19, RCW 9.46.110).
Prizes paid, never prizes configured. prizesPaid is the retail value of every prize held by an
unforfeited winner — the set Module 11’s UNIQUE (active_prize_key) index guarantees contains each
prize at most once. A prize won but not yet delivered counts; a prize nobody won contributes nothing
and is printed as its own prizesConfiguredNotAwarded line, because it raises the taxable figure.
financial-summary’s `netReceiptsProvisional uses configured prize exposure and must never be
filed on.
Finalization is the trigger Module 3 built its lock exception for. It writes the frozen worksheet
and raffle.tax_rate_finalized_at in one transaction; from that instant PATCH /raffles/{id}/tax
answers 409 TAX_RATE_FINALIZED. There is no PATCH, no DELETE and no regenerate: a worksheet with
a wrong figure is a defect in the records it was computed from.
The $10,000 exemption is claimed once per raffle, with a same-tax-year guard. RCW 9.46.110 exempts
per organization per year, not per raffle; where a second raffle completes in the same year,
finalization is refused with 409 EXEMPTION_ALREADY_CLAIMED unless acknowledgeSharedExemption is
true. The system does not apportion — that is the licensee’s filing decision.
B.24. Report catalogue
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
The catalogue: keys, titles, owning module, formats, parameters |
|
|
all authenticated roles |
Any report, uniformly, with a WAC 230-11-105 cover page |
|
Errors: 404 NOT_FOUND; the per-report fetch additionally 400 VALIDATION_ERROR.
Keys: record-set, tax-worksheet, reconciliation (needs from/to), financial-summary,
ticket-inventory.
An addition, not a migration. The existing module-specific endpoints are unchanged and clients bound
to them keep working. This surface adds one cover page, one EXPORT_PRODUCED emission and one hash
discipline — and brings reconciliation, financial-summary and ticket-inventory into PDF for the
first time. The figures still come from the module that owns them.
B.25. Encrypted raffle archives
The compliance bridge for R12’s "records maintained in Washington", whatever the hosting arrangement.
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
|
|
|
|
all authenticated roles |
Export history with hashes, sizes and record counts |
|
|
all authenticated roles |
One export, including |
|
|
|
The |
|
Errors: export — 400 ARCHIVE_KEY_WEAK, 409 CUSTODIAN_NOT_CONFIGURED, 404 NOT_FOUND; reads —
404 NOT_FOUND; content — 409 ARCHIVE_NOT_READY, 404 NOT_FOUND.
The passphrase is accepted only in the POST body, over TLS, and is never stored, hashed, hinted at
or returned. There is no escrow, no recovery and no reset: losing it means re-exporting.
Consequently there is no QUEUED state — the key lives only in the requesting process — and a restart
mid-export fails it unresumably, with that reason recorded.
There is no server-side decrypt endpoint, by design. Verification and extraction are the offline
utility’s job: cfoil archive verify|extract runs with no server, no database and no network, backed
by the shared :archive-format module. The format is AES-256-GCM in authenticated frames over a ZIP,
keyed by Argon2id, with the final-frame flag bound into every frame’s AAD so a truncated archive
fails verification rather than reading as a shorter valid one.
GET …/archives/{exportId} returns the live record counts that the archive’s manifest is compared
against — that comparison is the round-trip check, and it never requires the key to come back to the
server.
The event excerpt and the chain witness. The significant-event log is global and hash-chained, so a
per-raffle cut would have holes nobody could verify. The archive therefore carries the raffle’s own
events plus events/chain-witness.csv: (seq, eventHash, prevHash) for every event in the covered
range — hashes only, no payloads — so an offline verifier can prove the excerpt sits in an unbroken
chain without the archive containing one byte about another raffle.
B.26. Retention & destruction
| Method & path | Roles | Purpose | Success |
|---|---|---|---|
|
all authenticated roles |
Per record class: |
|
|
|
|
|
|
all authenticated roles |
Requests and permanent certificates |
|
|
|
|
|
|
all authenticated roles |
One permanent certificate |
|
Errors: retention — 404 NOT_FOUND, 409 FISCAL_YEAR_NOT_CONFIGURED; request —
409 RETENTION_NOT_EXPIRED, 409 ARCHIVE_REQUIRED, 409 CUSTODIAN_NOT_CONFIGURED; list —
404 NOT_FOUND; approval — 400 VALIDATION_ERROR (missing approve or note),
409 DESTRUCTION_NOT_PENDING, 409 DESTRUCTION_APPROVAL_SELF,
409 RETENTION_NOT_EXPIRED, 409 ARCHIVE_REQUIRED; certificate — 404 NOT_FOUND.
Record classes: RAFFLE_RECORDS (3 years from fiscal-year end), TICKET_STUB_DETAIL (1 year),
COUNTERFOIL_PHYSICAL (30 days from the drawing or until the prize is awarded — advisory, since
this system holds no paper).
NOT_COMPUTABLE is a real answer, not a failure: no fiscal-year end is configured, or a winner has
not claimed so R12’s "or until the prize is awarded" half is genuinely unresolved. A guessed date on a
compliance screen would be worse.
Destruction is redaction, never deletion. Every count, figure, draw number and hash survives; only personal detail is overwritten — so the record set still reconciles the day after one runs. Frozen artefacts are never destructible: receipts, drawing reports, counterfoil batch manifests, tax worksheets and the event log. WAC requires their retention and nowhere requires their destruction.
A completed archive is a hard precondition (ARCHIVE_REQUIRED). The obligation is to maintain
records in Washington; destroying the live copy with no archive on file is data loss with a form
attached. A refusal inside the retention window is itself a significant event — somebody tried to
destroy records early.
Approval must be a different user from the requester, and every precondition is re-checked at approval rather than trusted from request time.
The decision is stated, never assumed. approve is required: a body without it, or no body at
all, is refused with 400 VALIDATION_ERROR naming approve and nothing is redacted. It is never read
as approval. note is required for both decisions.
B.27. Records profile
| Method & path | Purpose |
|---|---|
|
The WAC 230-11-105 custodian, records location, fiscal-year end, and profile hash |
Success 200 OK. Roles: all authenticated.
There is no write endpoint, by design — the same call /organization makes, for the same reasons.
One server serves one legal entity, so this is a JSON file (COUNTERFOIL_RECORDS_FILE) read once at
startup, validated and hashed. Changing it is an edit plus a restart, and its history lives in the
append-only event log where no role can edit it. The accepted cost, stated rather than discovered: a
change is recorded at the next startup, not at the moment of the edit.
A separate file from organization.json, deliberately. The organization profile is frozen onto
every raffle at its pre-sale lock, so receipts print the licensee as it stood when selling began.
Custodian data has the opposite lifetime: it changes across a three-year retention window and today’s
filing must name today’s custodian. Merging them would freeze stale custodian data onto locked
raffles — and would change every existing deployment’s organization profile hash.
Absent, the server starts and ticket sales are unaffected; filing a worksheet, exporting an archive
and destroying records all refuse. Present but malformed, incomplete, non-Washington or PAN-bearing,
startup fails naming the field — state must be WA, because R12 requires records maintained in
Washington and a custodian elsewhere is not a control.
When unconfigured this endpoint answers 200 with configured: false and the path the loader looked
at, rather than 404.
B.27.1. Events
Generating the record set or finalizing the worksheet emits REPORT_GENERATED; every report fetch
and every archive export emits EXPORT_PRODUCED carrying the content or manifest hash — never a
purchaser name and never the archive passphrase. Custodian and jurisdiction changes emit
CONFIGURATION_CHANGE (entity_type = records_profile / tax_jurisdiction). Destruction requests,
approvals, rejections and refusals emit RECORD_MODIFY on destruction_requests.
Executed destruction emits the new RECORDS_DESTROYED type (entity_type = record_destructions).
It is the only new event type in this release, and it earns one: what has this system destroyed, when,
and on whose authority is the first question asked of a retention feature and must be answerable from
one type filter — and this is the only irreversible, data-losing operation in the product.
B.28. Changelog
Record every contract-affecting change here so clients can track the API over time. Newest first.
| Date | Component(s) | Change |
|---|---|---|
2026-07-30 |
Record set, tax worksheet, report catalogue, archives, retention & destruction, records profile, tax jurisdictions |
Added the WSGC standard record set ( |
2026-07-29 |
Prize records, reserve balances, winners & fulfilment, drawing sessions |
Added prize evidence ( |
2026-07-27 |
Counterfoils, print runs, verification |
Added counterfoil batches ( |
2026-07-26 |
Receipts, print batches, organization, raffle configuration |
Added receipts ( |
2026-07-26 |
Payments, refunds, reconciliation, order intake |
Added the record-only payment ledger ( |
2026-07-25 |
Order intake, sales units |
Added the sales-entry API ( |
2026-07-19 |
Ticket inventory & draw numbers |
Added the reconciling ticket inventory ( |
2026-07-16 |
Raffle configuration, tax jurisdictions |
Added raffle configuration ( |
2026-07-15 |
Significant event log |
Added the significant-event interrogation search ( |
2026-07-13 |
Authentication & sessions, user administration |
Initial API definition: conventions, roles, auth ( |
Appendix C: Changelog
All notable changes to Counterfoil are recorded here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Counterfoil ships as 13 small, independently deliverable modules (see the product PRD); each module is a minor release on the road to v1.0.
C.1. [0.14.0]
The administration web interface’s Phase 1 and Phase 2 ship together as 0.14.0. The release date is added to this heading when the release is cut.
C.1.1. Administration web interface, Phase 2 — approvals and oversight
Added
-
Void approvals in the web interface. A Raffle Administrator opens a locked raffle’s void-request queue from its page, filtered to pending by default, and approves or rejects each request through a confirmation that shows the draw number, the ticket’s current status, the reason and who asked, and that requires a note. Approve is never offered on one’s own request; Reject is, as a withdrawal.
-
Refund approvals in the web interface. A locked raffle’s refund-request queue, beside its void queue. Approving confirms the consequence read fresh from the order — total, paid now, the requested amount and every draw number that will be voided — warns when the paid amount has moved since the request was opened (the server will refuse it), takes an optional payment date and a required note, and lands on the request’s own page showing what it did. A refusal because a ticket on the order has a pending void points to the void queue. Purchaser details are not shown.
-
Ticket inventory in the web interface. A locked raffle’s reconciling report — per series and in total — drawn as an alarm when unissued + issued + voided does not equal the configured total, above a ticket list filtered by status, series and pending void, with a lookup by draw number. Read-only.
-
What is waiting, at a glance. The raffle list shows each raffle’s pending void, refund and destruction requests, counted when the page loads (a draft shows a dash for voids and refunds, since it cannot have sold a ticket). A locked raffle’s page gains an oversight panel with the same counts linking to each pending queue, the ticket inventory and its destruction requests.
-
Destruction approvals for the Raffle Administrator. The destruction-approval screen, until now System Administrator only, is open to the Raffle Administrator too, as the API always allowed.
-
Event log search in the web interface. The Raffle Administrator and the System Administrator search the significant-event log by time, event type, actor and entity, paged, with each result’s actor and entity a link that narrows the search to it. Time bounds are entered in the browser’s zone, refused on a daylight-saving gap or overlap, and read as UTC — which the page says — when scripts are off.
-
Who acted is returned beside the id. Void-request, refund-request, destruction and event responses carry
requestedByIdentity,approvedByIdentityandactorIdentitybeside the user ids they already return, in the formFull Name <email>, resolved from the user’s current row at read time and never stored. Sign-in events (LOGIN_SUCCESS,LOGIN_FAILURE,ACCOUNT_LOCKOUT) carry no identity, for privacy.GET /usersstays System Administrator only. -
Void requests carry their ticket’s
drawNumber. Ticket list rows carryseriesCode,issuedAtandvoidedAt, and a ticket lookup itsseriesCode. -
The refund-request list populates
orderReferenceon every row andvoidedTicketCountandvoidedDrawNumberson approved rows, which until now appeared on the approval response only. -
cfoilshows the identities, draw numbers, series codes and order references in its tables and record views. An earliercfoilkeeps working: it ignores fields it does not know.
Breaking changes
Every resolution of a two-person control now states its decision and why. Upgrade cfoil with the
server: an earlier cfoil resolving without a note is refused.
-
POST …/void-requests/{id}/approveand…/rejectrequirenote. -
POST …/refund-requests/{id}/approvaland…/rejectionrequirenote. -
POST …/destructions/{id}/approvalrequiresnoteand an explicitapprove. A missingapprovewas read as approval; it is now refused, and nothing is redacted. -
A missing,
null, empty or whitespace-only value, or a missing body, is refused with400 VALIDATION_ERRORnaming the field. Nothing changes and no event is emitted. -
cfoil’s resolve commands require `--note, andcfoil destruction approverequires--approveor--reject.
Every irreversible act now records why it was taken:
-
POST …/drawings/{id}/pool/closurerequiresnote. -
POST …/tax-worksheetrequiresnote, which is now stored and returned asfinalizationNote. Until now it was accepted and discarded. -
POST …/drawings/{id}/counterfoil-batchesrequiresreasonin every mode, not onlyREPLACEMENT. -
POST …/payments/{id}/reversalrequiresnotebeside the codedreason. -
POST …/sessions/{id}/closurerequiresclosingNote. When a prize went unawarded, the refusal still names the prizes (details.unawardedPrizeIds) and now alsoclosingNote. -
cfoil pools close,tax finalize,counterfoils generate,raffles payments reverseanddrawings closerequire--note,--reasonor--closing-note.
Database
-
V13adds the nullabletax_worksheets.finalization_note. It sits outsidecontent_jsonandcontent_hash, so no frozen worksheet’s hash, PDF or export changes, and nothing is backfilled. The archive’sreports/tax-worksheets.csvgains the column.
Changed
-
The destruction-approval screen names the requester from the response’s identity instead of reading the user record, which only a System Administrator may do.
-
The web interface’s navigation and home page list Raffles before the oversight areas (Destruction approvals, Event log) for a Raffle Administrator.
-
The Guide’s administration web interface section covers the Phase 2 areas, approvals and withdrawal, and the event-log time rules. Part 2’s void and refund examples now use
cfoil’s real options (--reason-text`, and a coded--reasonon a refund request).
Rollback
Rolling back to the previous jar is safe: V13’s column is ignored by the older code, stored notes
reappear on re-upgrade, and Flyway’s default `ignoreMigrationPatterns lets the older jar start with
a migration it does not know. The API then accepts resolutions without a note again. To remove only
the web interface, set COUNTERFOIL_UI_ENABLED=false and restart.
C.1.2. Administration web interface, Phase 1
This adds no /api/v1 endpoint, no schema change and no migration.
Added
-
A browser interface at
/ui/on the same port and from the same jar, for the System Administrator (users, sales units, tax jurisdictions, event-chain verification, destruction approvals) and the Raffle Administrator (raffles, their core fields and time zone, the tax rate, pricing plans, drawings and prizes). See Section 4.4. -
Every browser write calls the API’s own resource method in-process, so it writes the same rows, is attributed to the same user, emits the same significant events and is refused with the same code, rule and citation as the API. Every form
POSTdeclares the API method it fronts, and an architecture test fails the build if the browser offers an action the API lacks or admits a role the API does not. -
Raffle date-times are entered in the raffle’s own zone; a local time inside a daylight-saving gap or overlap is refused rather than guessed. Changing a draft raffle’s zone shows every affected moment in both zones and saves nothing until confirmed; a form opened before someone else changed the zone saves nothing and comes back re-read in the new zone.
-
Every removal, disable, retirement and destruction decision is confirmed on a server-rendered page before anything is written, so the confirmation holds with scripts off.
-
The startup capability report gains a
WEB UIline. -
One runtime library, JMustache, and one test-only library, jsoup; both listed in Appendix D.
Security
-
The session travels in a
__Host-cookie that isSecure,HttpOnlyandSameSite=Strict. Every formPOSTis checked for its origin and for a synchronizer token derived from the session, with nothing new stored./api/v1still authenticates from theAuthorizationheader alone, so a cookie-only API request is401. -
A strict Content-Security-Policy (
connect-src 'none', no inline script or style) makes "no request after load and no foreign origin" a property the browser enforces. -
Plaintext requests to
/ui/are refused (R15).COUNTERFOIL_UI_REQUIRE_HTTPS=falseexists forhttp://localhostdevelopment only and logs a warning.
Fixed
-
GETandPATCH /api/v1/raffles/{id},PUT …/tax-rateandGET …/compliance-checklistanswered404over HTTP, socfoil raffles get,update,tax-rateandchecklistfailed. The receipt and retention resources were mounted at/raffles/{raffleId}, which JAX-RS ranks aboveRaffleResource’s `/rafflesand then searches alone. They are now mounted at/rafflesand merge with it; no URL changed.
Deployment
-
The server honours
X-Forwarded-ProtoandX-Forwarded-Forfrom a reverse proxy on loopback or a private address, so/ui/works behind an nginx or Apache that terminates TLS with no extra configuration.COUNTERFOIL_FORWARD_HEADERS_STRATEGY=noneturns it off. -
COUNTERFOIL_UI_ENABLEDdefaults totrue. Setting it tofalseand restarting removes the interface with no effect on the API,cfoilor sessions — the rollback lever.
C.2. [0.13.1] — 2026-08-09
C.2.1. Application logging
No product change and no module, so a patch release: the Module N → 0.N.0 convention stays reserved
for product modules and 0.13.0’s minor slot is left unconsumed.
The system was functionally complete through Module 12 and effectively silent — 27 of 532 classes held a logger, and 51 of the 67 orchestrating services held none. Nothing in the significant-event log answers "is the delivery dispatcher draining the queue", "why did that session refuse to open" or "what is this process configured to do", because none of those is a significant event. This release gives the system an operational voice without widening the legal record.
Added
-
One correlation identity per request. Every response now carries an
X-Request-Idheader, and every log line produced while serving that request carries the same id along with the acting user, the raffle and the endpoint. A client may supply its own id; it is echoed and trusted for nothing else. -
A startup capability report — one block naming the build, the port, the database, whether selling is enabled (licensee profile), whether filing is enabled (records profile), the receipt channels and every sweep schedule. Its absence after a deploy is the signal that the running process is not the build you think it is.
-
Structured output on request:
COUNTERFOIL_LOG_FORMAT=ecsemits one JSON object per line with every correlation field as a top-level key. The default stays human-readable. -
Per-area log levels as environment variables (
COUNTERFOIL_LOG_LEVEL_POOLand five siblings), so raising the detail for one area is a deployment change rather than an image rebuild. -
Optional rotating file output via
COUNTERFOIL_LOG_FILE, capped at 100 MB × 30 days × 5 GB. -
cfoil --verbosenow prints elapsed time and the server’sX-Request-Idalongside the method, URL and status, still to stderr and still without headers or bodies.
Changed
-
Every scheduled job now reports one summary line with counts and a duration — and only when it did something. Ten schedulers tick as often as every thirty seconds; an unconditional line would be 2,880 daily entries of nothing, which teaches operators to filter the logger and takes the line that mattered with it.
-
The nightly integrity sweeps log each passing item at
DEBUGrather thanINFO, so a clean night is one summary line instead of hundreds and a finding is not buried. -
Each mapped HTTP failure is logged at a level derived from its status: 5xx with a stack, 401/403/423 at
WARN, other 4xx atDEBUG. A 4xx is the system working. -
PrincipalCleanupFiltermoved from@Priority(Integer.MAX_VALUE)toInteger.MIN_VALUE. JAX-RS runs the response chain in descending priority, so the old value made it run first among response filters rather than last, contrary to its own documented intent.
Security
-
The operational log carries no personal data at all — no names, emails (masked or not), addresses, phone numbers, card digits, session tokens or archive passphrases. People and records are named by id. This is deliberately stricter than the event log, which carries masked identifiers under access control: an application log is collected by infrastructure and cannot be reached by the retention and destruction paths that govern records.
-
org.hibernate.orm.jdbc.bindis pinned toWARNwith a warning in place and deliberately given no environment variable. AtTRACEit prints bound statement parameters — every purchaser name and address — past every other control in this release. -
A failed login records
userIdwhen the account resolved and the literalUNKNOWN_PRINCIPALwhen it did not. The no-enumeration posture governs the API response, which is unchanged and still identical either way; the operational record has always distinguished the two, because guessing a password against a real account and spraying addresses that do not exist are different attacks. The submitted address is never logged in either case. -
There is no endpoint to change a log level on a running server, deliberately. A running process able to change what evidence it produces about itself is a surface a certification lab must reason about, in a product built on its own records not being editable from inside it.
C.3. [Unreleased]
C.3.1. Documentation — worked examples of the three JSON configuration files
No product change, so no version bump.
Part 5 gains a Configuration file examples section: a complete, valid example of
organization.json, records.json and the bootstrap admin file, each with a field table marking
what is required and what the loader will refuse. Previously the guide named all three files and
said what happened when they were absent, but never showed one — so a first-time deployment had to
read the loaders to find the field names.
Every field and required/optional flag is taken from the record and the loader that validates it,
including the rules that fail startup: records.json must name state: WA (R12), its
fiscalYearEnd is a real MM-DD, and five of its fields are card-number-checked. The bootstrap
file carries an explicit warning to delete it after first boot.
C.3.2. Documentation — GUIDE.md → AsciiDoc, with on-demand HTML and PDF
No product change, so no version bump — the module-per-minor convention (Module N → 0.N.0) is reserved for product modules, and this ships none.
GUIDE.md is deleted; docs/guide/ (seven AsciiDoc source files under docs/) is now the one source
for the Raffle Administrator’s Guide. The root build.gradle gains an opt-in documentation task
group — gradle docs renders the guide to a single self-contained HTML file and a PDF in
build/docs/, both git-ignored and neither wired into check or build. The Asciidoctor Gradle
plugin, AsciidoctorJ and JRuby it pulls in are build-time only and enter no artifact, so — like
Spotless and unlike PDFBox — none of them is a Module 13 SBOM or signature item.
Section numbers are generated by :sectnums: rather than hand-typed (verified to reproduce the
guide’s existing 1.1–1.7 / 3.1–3.8 / 4.1–4.2 numbering exactly), and every outbound repository link is
removed so the guide is self-sufficient for a reader with no repository access: the R1–R19/P1
regulatory constraint table is copied into a new Appendix A (checked against
features/2026-07-11-counterfoil.md by docs/verify-guide.py, which also checks the heading census,
table census, rendered numbering, reproducibility and HTML self-containment), and the cfoil client’s
own environment variables are documented inline in Part 5 rather than linked.
README.md and CLAUDE.md now point at docs/guide/index.adoc and gradle docs in place of
GUIDE.md.
C.3.3. Documentation — API.md and CHANGELOG.md → AsciiDoc, folded into the guide
Also no product change, no version bump. API.md and CHANGELOG.md are deleted; docs/guide/ is
now their one source too, as two more appendices in the same document rather than a second rendered
document sharing the pipeline — Appendix B and this changelog you are reading.
The conversion is mechanical: every table and its row count carry over exactly (41 tables, 234 rows
for the API reference, verified against the deleted file’s last committed form), and the one
out-of-sequence heading each file had (Authentication & sessions’s four endpoint headings in
`API.md, jumping to ; this changelog’s own [0.0.0] entry, jumping to ##) is
promoted one level, the same fix the guide conversion applied to Part 5’s Environment variables.
Every outbound repository link is removed under the same standalone-distribution rule as the rest of
the guide: links between API.md and CHANGELOG.md become internal cross-references now that both
live in one document (Appendix B, Appendix C), and links to module tech specs and
the PRD become plain text, since specs/ and the PRD are not part of the guide. API.md’s own
internal changelog section gets an explicit anchor id (`api-changelog) to disambiguate it from this
appendix — both are titled "Changelog", and only one may claim the default changelog id.
README.md, CLAUDE.md and cli/README.md now point at the two new appendices in place of API.md
and CHANGELOG.md. The historical specs/*.md files are left untouched: they record what was true
when each module was implemented, including the-then-real "API.md is updated as part of
implementation" convention, and rewriting history is not this change’s job.
C.4. [0.13.0] — 2026-07-30
C.4.1. Housekeeping — package root, test tiers, and code style
No behaviour changes and no new module. Three repository-wide refactorings, each verified against the same test suite: 2,153 tests, 0 failures, identical counts before and after.
Changed
-
The Java package root is now
dev.cfoil, renamed fromorg.counterfoil. All 1,016 source files across the three Gradle modules moved together, along with thegroupcoordinate, thecfoilCLI’smainClass, and thelogging.levelkey inapplication.yml(nowlogging.level.dev.cfoil). Operators setting the log level by its fully qualified key must update it; nothing else is operator-visible. The architecture tier’s source-scanning paths moved with it — those tests readsrc/main/java/dev/cfoil/…directly, so a half-done rename would have made them scan nothing and pass vacuously. -
The test suite is two Gradle tasks instead of one.
testnow runs the unit and architecture tiers (1,524 tests, seconds, no Docker); the newintegrationTesttask runs the Testcontainers tier (571 tests, about a minute). The partition is exact — 1,524 + 571 is the 2,095 the single task ran before, with the 26 skips landing in the same places — and both are wired intocheck, sogradle buildstill means the whole suite. The split is by class-name pattern (*/*IT.class) over the onetestsource set rather than by a separate source set, because thedev.cfoil.supportfixtures are shared by both tiers and separating the source sets would force a third source set to hold them. The Jenkinsfile gains a matching *Integration Test stage after Unit Test, so a unit failure fails the build before anything pays for a container start. -
Code style is Google Java Style, applied to every Java file and enforced by google-java-format 1.36.1 through the Spotless Gradle plugin: two-space indent, 100-column limit, formatter-owned import order.
gradle spotlessApplyreformats andspotlessCheckruns insidecheck. Both are build-time only — they ship in no artifact and are neither compile nor runtime dependencies, so unlike PDFBox they are not Module 13 SBOM or signature items. The style is configured once in the rootbuild.gradlefor all three modules, for the same reason the archive container is one module: two definitions of a format fork silently.
Notes
-
The architecture tier’s banned-pattern scans (the R16
Instant.now()ban, the R2 no-RNG ban) survive the reflow intact — their regexes already tolerate newlines between tokens, and both were re-verified by injecting a real violation and confirming the scan still fails on it, rather than by observing that they still pass.
C.5. [0.12.0] — 2026-07-30
C.5.1. Module 12 — WSGC Recordkeeping, Reporting, Local Tax & Retention
The standard record set, the King County gambling-tax worksheet, the encrypted full-raffle archive, and the retention clocks. Spec: the Module 12 tech spec. Traceability: R12 (records in WSGC format, retained 3 years from fiscal-year end / 1 year for stub detail, maintained in Washington), R17 (electronic accounting and reporting), R18 (gross receipts determinable from discounted sales), R19 (local gambling tax ≤ 5% of net, first $10,000 exempt).
Added
-
The WSGC standard record set — composed, never calculated. Every figure comes from the module that owns it: gross receipts and the R18 breakout from Module 6, ticket counts from Module 4, pools from Module 9, batches from Module 8, drawing results from Module 10, winners from Module 11. The only arithmetic this module performs is net receipts and the tax. A
SELECT SUM(…)here would be a second definition of gross gambling receipts, and this codebase already decided — when Module 8 was forbidden from re-deriving pool membership — that a second implementation of a legally sensitive figure is the worst available outcome. An architecture test now enforces it. -
A reconciliation gate with ten identities, and the list is readable whether or not it blocks. The most valuable of them is the cache-versus-ledger check:
raffle_orders.amount_paidis a cache ofSUM(payments.amount)by design, and this is the one place in the product that proves it never drifted. Drift would misstate gross receipts in a way nothing else would catch. -
The local gambling-tax worksheet, and the trap it avoids. Module 6 publishes
FinancialSummary.netReceiptsProvisional, computed against configured prize exposure and labelled provisional in its own javadoc. Filing on it would be wrong by up to the value of the grand prize. The worksheet uses prizes paid — the retail value of every prize held by an unforfeited winner, which is exactly the set Module 11’sUNIQUE (active_prize_key)index guarantees contains each prize at most once. Prizes configured but never awarded contribute nothing and are printed as their own line, because they raise the taxable figure. -
Finalization closes the seam Module 3 opened.
RaffleService.updateTaxRatehas refused withTAX_RATE_FINALIZEDsince Module 3 shipped, gated on a column nothing wrote. This module is that column’s only writer, and it writes it in the same transaction as the frozen worksheet. -
The $10,000 exemption is claimed once per raffle, with a same-tax-year guard. RCW 9.46.110 exempts per organization per year, not per raffle; the two readings coincide for a licensee running one enhanced raffle a year, which is this product’s target. Where they diverge, finalization refuses with
EXEMPTION_ALREADY_CLAIMEDunless acknowledged. The system does not apportion — that is the licensee’s filing decision, not a rule to invent here. -
Encrypted per-raffle archives (
.cfar), verifiable and extractable with no running Counterfoil. AES-256-GCM in authenticated frames over a ZIP, keyed by Argon2id, in a new:archive-formatGradle module shared by the server andcfoil— because two implementations of a cipher is how a format silently forks, and because one small artefact is what a certification lab can inspect. No new third-party dependency: the container is JDK, and Argon2id comes from the bouncycastle already present for password hashing. -
The chain witness, which is what makes a per-raffle cut of a global hash-chained log verifiable. Exporting only a raffle’s own events would leave holes nobody could distinguish from deletions. The archive therefore carries
(seq, eventHash, prevHash)for every event in the covered range — hashes only, no payloads — so an offline verifier re-links the excerpt into an unbroken chain without the archive containing one byte about another raffle. -
Retention clocks per record class, including the one only Module 11 made computable: R12’s "30 days from the drawing or until the prize is awarded" needed a delivery date, and
winner_fulfilments.delivered_onis it.NOT_COMPUTABLEis a first-class answer rather than a guessed date on a compliance screen. -
Destruction as redaction under a permanent certificate, two-person and archive-gated. Every count, figure, draw number and hash survives; only personal detail goes — which is why the record set still reconciles the day after a destruction runs.
RECORDS_DESTROYEDis the only new event type in this release and the first since Module 7. -
A uniform report surface (
/raffles/{id}/reports) bringing reconciliation, the financial summary and the ticket inventory into PDF for the first time, with one cover page, one export event and one hash discipline — and zero changes to the services that own those figures. -
The records custodian as a hashed JSON file (
records.json,COUNTERFOIL_RECORDS_FILE), deliberately separate fromorganization.json.
Changed
-
GET /raffles/{id}/winner-registergains custodian fields in its identity block — the slot Module 11 reserved. They are identity, not figures, so the register’s content hash is unchanged. -
POST/PATCH /tax-jurisdictionsare new andSYSTEM_ADMINISTRATOR-only. The segregation harness gains two documented entries: the lookup is reference data about municipal ordinances, not a gaming record, and it is structurally incapable of altering a raffle — the jurisdiction is snapshotted onto the raffle at configuration and frozen again onto the worksheet at finalization. -
EventCanonicalizerandEventHashermoved to:archive-formatso the offline verifier runs the exact code that sealed the chain. Same package, so no import anywhere changed and Module 2’s hash tests moved unchanged as the gate on that. -
Module 3’s "the jurisdiction lookup exposes no mutating endpoint" assertion became the sharper one it always stood for: whoever may write it, it must be the System Administrator and nobody else.
Deployment
-
COUNTERFOIL_RECORDS_FILEmust be set and the file placed before any filing, archive or destruction. Nothing else notices its absence, so a smoke test will not find it —GET /api/v1/records-profileis the check.statemust beWA: R12 requires records maintained in Washington, and the server refuses to start otherwise. -
V12__recordkeeping_tax_retention.sqladds four tables and alters none. The custodian needs no migration, being a file. -
Rolling back to 0.11.0 leaves the four tables unused and ignores
records.json. The tax-rate freeze survives a rollback, because Module 3’s refusal reads the column the rolled-back build still honours. A rolled-backcfoilrendersRECORDS_DESTROYEDas a raw string.
Known gaps
-
The archive passphrase is unrecoverable. No escrow, no hint, no reset — and no
QUEUEDexport state, because a queued job would need the key to outlive the request. A restart mid-export fails it unresumably, with that reason recorded. -
Frozen artefacts are not destructible: receipts, drawing reports, counterfoil batch manifests, tax worksheets and the event log are retained permanently. WAC requires their retention and nowhere requires their destruction, so a winner’s name survives in an immutable R17 report after a purchaser redaction.
-
RetentionDestructionPortis declared and consumed by nothing: it exists for Module 13’s row-level integrity sweep, so a lawful destruction is never reported as tampering. -
Confirmed Decisions 1–3 in the spec (what "net receipts" means for R19, exemption apportionment, and whether a prize won-but-undelivered is "paid") need compliance-lead ratification before the first filing.
C.6. [0.11.0] — 2026-07-30
C.6.1. Module 11 — Prizes, Reserve Tracking & Winner Fulfilment
Prize inventory evidence, the WAC 230-11-065 reserve requirement, and post-drawing fulfilment. Spec: the Module 11 tech spec. Traceability: R11 (both halves — unencumbered funds ≥ cash prizes, and demonstrable availability of noncash prizes), R10 (a prize record may not restate a disclosed value), R12 (the prize-and-winner register is a retained WSGC record; the delivery date is what finally resolves "printed tickets kept ≥ 30 days or until the prize is awarded").
Added
-
Prize records — and with them, the cash/noncash classification nothing else in the product had. Module 3 stores a prize’s disclosure and has never needed to know whether satisfying it takes money out of the bank or a car off a lot. R11 needs exactly that. So a prize record is the input requirement of the reserve check, not paperwork: without one the obligation cannot be stated at all, which is why the PRD’s "no prize record, no drawing session" is a hard precondition.
-
Evidence lives outside the pre-sale lock, then acquires its own. A dealership signs in November for a December drawing, so requiring the paper before the first ticket is sold would make Module 3’s lock unsatisfiable. A prize record is therefore editable after it — constrained only in that
retail_valuemust equal the locked disclosure (PRIZE_VALUE_MISMATCH) — and freezes the moment a drawing session opens against it (PRIZE_RECORD_LOCKED), because that session was gated on this evidence and froze a reserve position computed from it. -
An append-only ledger of balance observations. No update path, no delete path; a mis-keyed figure is corrected by recording the correct one. Deliberately no reversal entry type, unlike
payments: a payment accumulates so a correction must subtract, but a balance is an observation of a level, so a correction supersedes and the latest observation as of a date is the truth. Two dates, never interchangeable —as_of_dateis what the statement speaks to,recorded_atwhat the clock says. -
The real R11 check, replacing the placeholder.
PreliminaryReserveCheckis deleted andReserveCheckServicebound in its place;ReserveCheckPortis unchanged, so no line ofdraw/changed to consume it. The obligation spans the drawing’s calendar date in the raffle’s own time zone, not the drawing — two drawings the same evening draw on one balance, and a per-drawing check would report both as covered. -
NOT_AVAILABLEandFAILEDkept strictly apart.FAILEDmeans the check was performed and the funds are short.NOT_AVAILABLEmeans it could not be performed: no balance, a stale balance, a prize with no record, or a percentage-of-receipts prize this version has no valuation rule for. Reporting any of those asFAILEDwould put a false claim about a licensee’s bank account into an immutable R17 artefact. -
Winner fulfilment, created inside Module 10’s award transaction. No winner without a fulfilment record and no fulfilment record without a winner.
PENDING → AWARDED | FORFEITED_REDRAWN, both terminal, each transition carrying its user, instant and reason. -
Forfeiture as a database interlock. A stored generated column that nulls itself on forfeiture, under a
UNIQUEindex, enforces at most one unforfeited winner per prize across sessions — so forfeit-before-redraw is the only order the schema accepts. Attempt the redraw first and the award fails on the key. -
The prize-and-winner register (
/raffles/{id}/winner-register) as JSON, CSV and PDF, with a content hash over the figures alone. A live view, so it gets content-hash reproducibility rather than the byte reproducibility a frozen artefact gets — freezing it would make it a second, staler drawing report. -
PrizeIntegritySweeper, nightly and never repairing: winners without a fulfilment record, prizes held twice, terminal rows missing required fields, evidence disagreeing with its disclosure, sessions opened without complete evidence, and frozen reserve figures with no traceable observation.
Changed
-
SessionOpenGategains three non-overridable blockers —PRIZE_RECORD_REQUIRED,PRIZE_DOCUMENTATION_REQUIREDandALL_PRIZES_AWARDED— reaching it throughPrizeReadinessPort. Not overridable is the PRD’s choice, not an invention: AC #1 says a session "cannot open", with none of the override language AC #5 attaches to the reserve. Unlike a bank balance, each is a fact about this database with a thirty-second remedy. -
DrawEventService.awardopens the fulfilment record throughWinnerFulfilmentPort, in the same transaction. One additive call; the dependency direction is unchanged. -
The reserve figures a session freezes change meaning.
reserve_cash_prize_totalnow excludes noncash prizes and spans the day’s drawings,reserve_recorded_balanceand its source document stop being null once a balance is on file, andreserve_statuscan bePASSED— soreserve_overriddenshould start readingfalse. Sessions already closed keep the snapshot they froze; nothing recomputes a closed session.
Notes
-
Not built, by design: no prize procurement or supplier management (a supplier is a string), no bank or escrow integration, no encumbrance ledger, no automatic balance import, no returned-prize state, no reserve override for the prize-side blockers, and no tax computation or withholding of any kind — L5 is reserved as an
attributes_jsonarea that nothing reads. -
The freshness window is code, not configuration (
ReserveLimits.BALANCE_MAX_AGE_DAYS, 7 days): whether a WAC check can be considered performed is a compliance question. The noncash documentation threshold is configuration, because the PRD says "the configured threshold" and no WAC provision names a figure. -
reserve_balancesis not trigger-protected, following Module 6’s decision that the significant- event log is the only trigger-protected store; the sweeper carries that burden instead.
C.7. [0.10.0] — 2026-07-29
C.7.1. Module 10 — Drawing Execution & Winner Recording
Supports the live, physical drawing: the readiness gate, the ordered draw sequence, the two-person winner control, and the Raffle Drawing Report. Spec: the Module 10 tech spec. Traceability: R2 (winners are drawn physically — nothing here can produce a draw number), R11 (the reserve check snapshot at session open), R12/R17 (the drawing report is a retained WSGC record), R13 (a voided or unpaid stub cannot be confirmed as a winner).
Added
-
A draw event cannot exist without a human’s input string.
draw_events.entry_methodandraw_inputareNOT NULL, so a draw the system produced for itself cannot be stored — there is no code path that creates one from a pool query and the database would refuse it. That is R2 enforced by schema rather than by review, in the module physically closest to violating it. -
The session-open gate, and what it will not waive. A session opens only for a closed, non-empty pool whose every eligible member has a printed stub, whose batches are all confirmed, whose printed stubs that have since lost standing are individually attested as pulled, and which has a prize configured. None of that is overridable, because each one means the barrel does not match the record. Only the R11 reserve check is, with a logged reason.
-
Attesting a withdrawn stub, by name. The system cannot see a barrel, so it asks: the attested set must equal the computed withdrawn-and-printed set — no blanket acknowledgement, an extra draw number is refused by name and a missing one blocks by name. The list is frozen onto the session and printed in the report.
-
The two-person winner control at one keyboard. The Raffle Administrator decides and a second authorized officer’s credentials are verified in-band, in the same request — no token issued, same lockout and rate limit as a login, and
awarded_by ≠ witnessed_byenforced by aCHECKconstraint.UNIQUE (session_id, drawing_prize_id)makes one winner per prize a database guarantee. Disqualification is deliberately single-actor: the remedy is the next draw. -
A rejected stub is a row, not an error.
MALFORMED,WRONG_RAFFLE,WRONG_DRAWING,NOT_IN_POOL,WITHDRAWNandALREADY_DRAWNare recorded with their sequence position, so the report shows every refusal in the order it happened rather than leaving it in an application log. -
The Raffle Drawing Report (R17), generated atomically with the close. There is no closed session without a report, no report without a closed session, and no regenerate endpoint.
content_jsonis the record; the PDF and CSV renderers are pure functions of it with dates and/IDfrom the report’s own instant and hash, so two fetches years apart return identical bytes. -
The Module 11 seam (
ReserveCheckPort). The v1 binding sums the drawing’s configured cash prizes and reportsNOT_AVAILABLE— it never returnsPASSED, because no unencumbered balance is recorded anywhere yet and a port that claimed a WAC 230-11-065 check it could not substantiate would be worse than none. Until Module 11, every session records an override whose reason carries the bank evidence. -
A nightly integrity sweep re-proving that every closed session has a report whose hash still recomputes, that the sequence is gapless, that every winner carries two distinct officers, and — the one that matters — that no winner sits outside the pool the session was opened against.
-
cfoil drawings {readiness\|open\|sessions\|draw\|events\|award\|disqualify\|close\|abandon\|report}.drawis a read-a-line loop, because a barcode scanner is a keyboard; it passes the line through verbatim and has no completion, no history recall and no "draw next" affordance anywhere.
Changed
-
Module 1 gains
AuthService.verifyCredentials— authenticates a second person without issuing a session or token, reusing the existing Argon2id verification, lockout counter and per-IP rate limiter. The only change to a shipped module.
C.8. [0.9.0] — 2026-07-28
C.8.1. Module 9 — Drawing Pools & Eligibility Engine
Encodes which tickets are in which drawing — the most legally sensitive logic in the system. Spec: the Module 9 tech spec. Traceability: R5 (eligibility standards fixed before any sale and computed from them alone), R6 (early bird winners stay in every subsequent early bird pool and the grand prize pool), R13 (a void keeps a ticket out of every pool), R18 (bundle tickets count individually toward a multiple-ticket threshold), R2 (nothing here selects a member).
Added
-
A pool is a pure function of
(locked configuration, asOf). Membership recomputes from immutable inputs only — the locked drawing’s typed parameters,placed_at,issued_at/voided_atand Module 6’s as-of ledger form, never the cachedpayment_statuscolumn. Given the same instant it returns the same set today and in the 2029 audit. -
Pool closure: one irreversible freeze per drawing.
OPENis the absence of adrawing_poolsrow andCLOSEDis its existence, soUNIQUE (drawing_id)is the irreversibility — there is no reopen path to audit for. Closing is refused before the drawing’s entry cutoff has passed, which is what makes irreversibility survivable: a pool closed before the sales it was meant to contain cannot happen rather than being undoable. -
Membership and standing are separate questions. Membership is frozen and can only shrink after closure, never grow; standing — can this stub still win — is computed live, so a void or a payment reversal takes effect immediately with no write anywhere. Withdrawn members stay in the frozen list, named with a reason and an instant, because the stub is physically in the barrel and someone has to pull it.
-
Three rules behind a registry (
GRAND_V1,EARLY_BIRD_V1,MULTIPLE_TICKET_ALL_TICKETS_V1), each a pure function with no clock, repository or randomness, and each generating its own plain-English statement so the rule and its description cannot drift. Adding refer-a-friend (L4) is one new class: no switch to extend, no registry to edit, no existing rule to touch.rule_idis versioned and frozen onto every closed pool. -
The mail-in-transit fail-safe. The cutoff bounds the sale; the closing instant bounds the payment. An administrator choosing when to close is told how many pre-cutoff orders have not paid and must acknowledge excluding them permanently (
POOL_PENDING_PAYMENTS). -
The determinism guarantee as a live endpoint (
GET …/pool/verification) and a nightly sweep: recompute the pool at its own closing instant and compare both the set and the hash to what was frozen. It never repairs — a mismatch means something immutable moved, and rewriting the snapshot to agree would destroy the only evidence.deterministic: nulldistinguishes "closed under a superseded rule version, not reproducible by design" from "corrupt". -
cfoil pools {list\|show\|members\|close\|verify}.closeprints what it is about to freeze and then asks the operator to type the drawing’s name — reserved for this one command in the whole CLI, because it is the only irreversible act a single operator can perform alone in Modules 1–10.
Changed
-
Module 8’s placeholder pool provider is deleted, exactly as its spec committed.
DrawingPoolPortis unchanged and no line ofcounterfoil/changed; Module 8’s entire integration tier now runs against the real engine, which is the reconciliation its spec promised. -
MULTIPLE_TICKETcounterfoil barrels can now be printed. They previously returnedFEATURE_NOT_AVAILABLE. -
Counterfoil batches cut against a closed pool now record
poolBasis: CLOSEDwith a realpoolId— the first time that column has been anything butPRELIMINARY. -
counterfoil_batches.pool_idbecomes a real foreign key now thatdrawing_poolsexists.
C.9. [0.8.0] — 2026-07-27
C.9.1. Module 8 — Counterfoil Generation & Print Management
Produces the physical counterfoils a winner is hand-drawn from, with verifiable completeness. This is the module that gives the product its name. Spec: the Module 8 tech spec. Traceability: R2 (nothing here selects a number — the draw is physical), R10 (draw numbers and the licensee disclosures on the printed stub), R12 (the manifest is a retained record; printed tickets kept ≥ 30 days), R13 (voided tickets never reach the barrel), R17 (manifest totals and checksums feed the Raffle Drawing Report).
Added
-
A counterfoil batch is a materialized, immutable set — not a query. Generation resolves the drawing’s eligible pool at one instant and writes one row per stub, freezing the purchaser name, city, state and phone that print on it. Every input moves underneath a printed barrel — a purchaser can be corrected, a ticket voided, a payment reversed — so the record of what went into it is frozen too. A batch is reproducible from its stored
poolAsOfalone. -
The pool seam (
DrawingPoolPort), which Module 9 will own. Module 8 does not compute eligibility: those are the most legally sensitive rules in the product, and duplicating them to satisfy a delivery order would leave two implementations to reconcile. The v1 binding implements only what shipped modules already determine — paid (Module 6’s as-of ledger form, never the cached column) ∧ not void ∧ ordered on or before the drawing’s cutoff, which is the PRD’s grand-prize and early-bird rule verbatim.MULTIPLE_TICKETpools returnFEATURE_NOT_AVAILABLEuntil Module 9, the precedent Module 3 set forREFER_A_FRIEND. -
Supplement and replacement batches. A supplement takes the eligible pool minus every member of every active batch, which makes "the union of manifests equals the eligible pool" true by construction rather than by hope. A replacement supersedes every active batch, requires a reason, and is
RAFFLE_ADMINISTRATOR-only; superseded batches and all their stubs are retained forever. -
Print runs over contiguous sequence ranges, each independently confirmed. A roll that binds at stub 8,412 of 20,000 costs one resume run, not a reprint of the first 8,411. A batch is printed when the union of confirmed ranges covers it; coverage is computed from the runs and cached nowhere, because it is the one fact in this module a human certifies.
-
Rendering bytes is not printing, and printing is not confirmation. Streaming a run marks nothing; only an operator’s explicit confirmation records stubs as being in the barrel. A jam is an abandon with a required reason that leaves coverage untouched, and a run left open past its expiry is auto-abandoned — a forgotten run must not leave a batch looking half-printed, and must not leave it looking printed either.
-
Two thermal profiles and two sheet profiles, one document.
ESCPOS_80is the reference — a receipt printer with a partial cut between stubs, which is exactly the tear-off artifact a barrel wants.ESCPOS_58carries a QR code instead of Code 128, which is not reliably scannable across a 384-dot head.PDF_LETTER/PDF_A4print 4-up with cut guides, and remain because 20,000 stubs on a thermal roll is roughly 800 m of paper and 90 minutes of continuous printing. All four carry the identical payloadCF1:<raffleId>:<drawingId>:<drawNumber>. -
Byte-deterministic rendering. A renderer is a pure function of the frozen rows and the profile — no repository, no clock, no randomness — and the PDF profiles take their dates and
/IDfrom the batch’s own generation instant and content hash. Two renders a restart apart produce identical bytes, which is what makes a reprint provably the same stub. -
The Counterfoil Manifest (JSON, CSV, PDF): counts, compressed draw-number ranges, the batch checksum over the ordered member hashes, the generating user, the supersession chain, the full print-run history, and the R12 physical-retention date. Regenerating from unchanged rows yields an identical checksum.
-
The automated manifest-vs-pool verification, and the
readyForDrawingpredicate Module 10 will gate a drawing session on.printedNotInPoolis the alarm: a ticket voided or reversed after its stub was printed means a physical stub in the barrel that must be pulled by hand. -
cfoil counterfoils— generate, list, get, members, manifest, verify, preview, print, runs, confirm, abandon.printopens a run, streams the server’s bytes verbatim to a printer (tcp://host:9100,file:///dev/usb/lp0,cups://queue, or-), then asks the operator whether it came out. It performs no layout, no encoding and no formatting: the server is the signed, lab-certifiable artifact, and rendering in an unsigned client would put a $1M drawing’s printed evidence outside the certified boundary. A transport failure never resolves the run — it exits40and says plainly that only a human can tell how much reached paper. -
New error codes
COUNTERFOIL_POOL_EMPTY,COUNTERFOIL_BATCH_SUPERSEDED,PRINT_RUN_NOT_OPEN,PRINT_RANGE_INVALID. MigrationV8__counterfoils.sqladdscounterfoil_batches,counterfoilsandcounterfoil_print_runs; it alters nothing existing.
Changed
-
DrawingRepositorygains aFOR UPDATEread of the drawing row, which batch generation takes as its first statement — that ordering is what stops two administrators cutting overlapping member sets, and a duplicate stub in a barrel is a second chance to win. -
The CLI’s
ApiClientgains a streaming download beside the buffering one, and a profile gains a defaultprintertarget (cfoil config set-profile --printer …, orCOUNTERFOIL_PRINTER). -
Lock order extends to
… → receipts → drawing → counterfoil_batches → counterfoils → counterfoil_print_runs → event chain head.
Not added, deliberately
-
No new significant-event type. A jammed run is an operational event with a reason, fully recovered by the next run and answerable from one
entity_typefilter; it is not a delivery obligation like R9. An unbalanced verification is aREPORT_GENERATEDcarryingbalanced: false. -
No purchaser identities in event summaries — counts and hashes only. Twenty thousand names in an append-only, un-deletable log is a retention defect no role could undo.
-
No printer driver, spooler, device discovery, printer-status polling, stored rendered document, barcode library, per-stub status column, or any function that selects, samples or shuffles a member (R2 — permanently, not deferred; enforced by a source-scan architecture test).
Known gaps
-
Module 8’s test tier is outstanding, alongside those still owed for Modules 6 and 7. The load-bearing cases are listed in the spec’s testing-obligations section: byte determinism across a restart, membership correctness under mutation, batch disjointness, concurrent generation on one drawing, the jam-and-resume coverage arithmetic, and the
printedNotInPoolalarm. -
Every batch records
poolBasis: PRELIMINARYandreadyForDrawingis never true, because pool closure arrives with Module 9. Theblockerslist says so explicitly rather than implying the barrel is final.
C.10. [0.7.0] — 2026-07-26
C.10.1. Module 7 — Receipts & Confirmations
Delivers the receipts RCW 9.46.0323(3)(j) requires, by the two channels it authorizes. This is the first module with an outbound dependency of any kind, and the design is shaped almost entirely by containing that fact. Spec: the Module 7 tech spec. Traceability: R9 (receipts with ticket confirmation numbers, by mail or email), R10 (required disclosures), R12 (records retained and reproducible), R15 (encrypted transport for raffle data leaving the system).
Added
-
A receipt is an immutable snapshot, not a query. Every R9/R10 disclosure — organization name and license number, raffle name, purchaser, order reference, every draw number, plan option and amount paid, each drawing’s date/time/location, the winner-present flag, prize descriptions and any disclosed minimum — is resolved and frozen at issuance. Re-sending can never change what a purchaser was told, even after their address is corrected. A correction is a reissue: a new version supersedes the prior one, which is retained.
-
Issuance is automatic. A sweeper issues a receipt for every fully paid order, so no operator has to remember. It runs on a schedule rather than inside Module 6’s payment transaction on purpose — putting it there would hand the money path an outbound dependency it deliberately does not have.
PaymentEligibilityPortis the sole gate: partial payment means no receipt. -
Mail is a batch PDF (Apache PDFBox), rendered from the snapshot on demand and never stored. The render is byte-deterministic — document dates and
/IDcome from the receipt’s own issuance instant and content hash, not the wall clock — so a reprint years later is provably the same document. Each receipt starts on a fresh page, so a run never puts two purchasers on a sheet. -
Print runs separate rendering from evidence. Generating a batch marks nothing; a receipt becomes "printed for mail" only when an operator confirms the run, with their user id and the time. A jam is an explicit abandonment with a reason that returns every member to the queue, and a run left unconfirmed past its expiry is abandoned automatically so a forgotten batch cannot strand receipts.
-
Email goes through a transactional provider behind a transactional outbox. The provider is called by a scheduled dispatcher with no database transaction open, so an outage delays receipts and breaks nothing. Claiming is a conditional UPDATE, which is the whole concurrency story. Delivery is at-least-once: providers offer no idempotency key, and R9 settles the choice between an occasional duplicate and an occasional silence.
-
Bounces are polled, not received. A webhook would put an unauthenticated inbound endpoint on a system whose whole compliance posture is that it has none, for latency that does not matter when the fallback is postal mail. A hard bounce queues a mail fallback and suppresses the address until it is corrected;
emailedAtstands, because the send did happen. -
The licensee is a hashed JSON file, not a table. One server serves one legal entity forever, so a one-row table models a cardinality that does not exist.
COUNTERFOIL_ORGANIZATION_FILEis read once at startup, validated and hashed, and its history lives in the append-only, hash-chained event log — where no role can edit it, unlike a table row.GET /organizationis read-only; there is no write endpoint anywhere. -
New error codes
RECEIPT_NOT_ELIGIBLE,RECEIPT_NO_EMAIL_ON_FILE,EMAIL_DELIVERY_DISABLED,ORGANIZATION_NOT_CONFIGURED,PRINT_BATCH_NOT_OPEN; one new event typeRECEIPT_DELIVERY_FAILED;cfoilgainsraffles receipts,raffles print-batchesandorganization.
Changed
-
A raffle can no longer start selling without a licensee on file.
lockForFirstIssuancegains a second precondition beside the existing license check: with no organization profile configured, the first ticket cannot be issued, so the raffle never opens. The refusal rolls the order back without consuming a draw number and is recorded asORDER_REJECTEDwith the new reasonORGANIZATION_NOT_CONFIGURED. The pre-lock compliance checklist gains anORGANIZATIONline item so this is discovered days before sales day rather than at the counter. -
rafflegainsorganization_profile_json/organization_profile_hash, frozen by the same atomic UPDATE that performsDRAFT → LOCKED. A later edit to the file can never retroactively change what a live raffle’s receipts say — the move Module 3 already makes with tax-jurisdiction facts, and it means an archived raffle names its own licensee with no configuration file present.
Fixed
-
A significant event emitted in a
REQUIRES_NEWtransaction was silently discarded when the outer transaction rolled back — which is the one job those emitters exist to do.ChainedEventWriterbuffers events on aTransactionSynchronizationManagerresource, and Spring clears synchronizations when it suspends a transaction but leaves bound resources in place. The inner transaction therefore found the outer transaction’s buffer, appended to it, registered no flush of its own, and committed having written nothing; the outer rollback then discarded the lot. The synchronization now implementssuspend()/resume()so each transaction gets its own buffer. This is a compliance fix for Modules 3, 5 and 6 as well as 7:ORDER_REJECTED(INVENTORY_EXHAUSTED,LICENSE_REQUIRED,ORGANIZATION_NOT_CONFIGURED),PAYMENT_REJECTEDandCONFIGURATION_LOCKED_ATTEMPTwere all lost whenever the refused transaction had already emitted an event — in practice, every refusal that happened after the purchaser record was written. The acceptance criterion "attempts … are rejected and logged" was not being met. A previously disabled Module 5 integration test covering this is now enabled and passing. -
A receipt reissue always failed with a duplicate-key error, so the module’s only path for correcting a receipt did not work.
current_order_keyis a stored generated column, and Hibernate orders INSERTs before UPDATEs within a flush, so the new version collided with the very row it was replacing. The supersede is now flushed before the new version is inserted. -
The loser of a concurrent issuance received a raw constraint error instead of the winner’s receipt. Because a receipt number is derived from the order id and version, concurrent issuers collide on
uq_receipts_numberas readily as onuq_receipts_current_order;ReceiptIssuanceCoordinatorrecognized only the latter. It now recovers from either, since both express the same fact.
Deployment
-
Write
organization.jsonand restart before anything else. Without it no raffle can issue a ticket. VerifyGET /organizationreturnsconfigured: true. UnlikeCOUNTERFOIL_BOOTSTRAP_FILE, this file is permanent — it is read on every boot. -
A deployment already selling under the 0.1.0–0.6.0 builds has raffles that locked before the frozen profile existed. A one-time backfill must set it on every already-
LOCKEDraffle before auto-issue is enabled, or their receipts cannot be assembled. -
Enabling auto-issue on a live raffle makes every already-paid order eligible at once.
max-per-sweepbounds the drain; decide with the operating org whether historical purchasers should be emailed at all (?deliver=NONEissues the record and queues nothing). -
Email is off by default. Enabling it needs
COUNTERFOIL_EMAIL_API_TOKEN, a from-address, and a verified sending domain (SPF/DKIM/DMARC). The base URL must behttps— startup fails otherwise (R15). -
New dependency in the signed build:
org.apache.pdfbox:pdfbox(Apache-2.0). It and the embedded receipt font belong in the Module 13 SBOM and artifact signature set — treat as a release-gate item.
C.11. [0.6.0] — 2026-07-26
C.11.1. Module 6 — Payment Recording & Reconciliation (Record-Only)
An append-only payment ledger and a reporting surface — not an integration. Counterfoil never processes a payment: card, check and cash handling all happen on the organization’s existing merchant facilities, and this module records what happened, when, and the processor’s transaction-ID reference. Spec: the Module 6 tech spec. Traceability: R11 (groundwork — gross receipts feed Module 11’s reserve check), R12 (gross, prizes and net computable at any time), R13 (a refund’s tickets are voided, never deleted), R18 (gross gambling receipts broken out by pricing-plan option).
Added
-
The payment ledger —
paymentsis append-only and signed, with exactly three entry types:PAYMENT(positive),REVERSALandREFUND(negative).SUM(amount)for an order is its amount paid, which makes "the report equals the sum of individual payment records" a definition rather than a reconciliation exercise. There is noPATCHand noDELETEon an entry: the record shows what was recorded and later corrected, not just the final number. -
Reversal ≠ refund. A reversal (mis-key, returned check, chargeback) says the money never arrived or was never this much — the order drops out of paid status and its tickets stop being drawing-eligible, but the purchaser still has a live order they can make good on. Only a refund returns money, and only a refund touches tickets.
-
The two-person refund control — one Finance/Records Officer requests, a different Raffle Administrator or Finance/Records Officer approves; approval writes the negative
REFUNDentry, voids every remaining ticket through Module 4’s newOrderTicketVoidPort, and closes the order — all in one transaction. There is no state in which a refund exists against a live ticket. Each voided ticket gets its ownticket_void_requestrow stamped with the refund id, so an auditor sees one decision that voided N tickets rather than N unexplained voids. -
Drawing-pool eligibility (
PaymentEligibilityPort) — an order’s tickets become eligible only when payments total the full order amount;PARTIALis not partial eligibility, it is no eligibility. Two forms: the materializedpayment_statusfor live queries, and an as-of form recomputed from the ledger’srecorded_at, which is what will make Module 9’s "recomputing a closed pool always yields the identical set" true rather than hoped for. -
No cardholder data, structurally —
card_last4is four characters, andPanGuardrejects any 13–19 digit sequence (separators between digits ignored) in every operator-supplied text field. The rejected value is never echoed back, logged, or written to the event log. -
Reconciliation report (
GET /raffles/{id}/reconciliation, JSON + CSV) — receipts by method and channel from the ledger, and the R18 gross-receipts breakout by pricing-plan option from each order’s immutable line snapshot, recognized when an order becomes fully paid and never prorated across installments. TheheldAgainstPartialOrdersbucket ties the two views together, so an order paid in installments across a period boundary balances in both periods. A report that does not balance is not returned with a warning flag — it fails with the discrepancy named. -
Financial summary (
GET /raffles/{id}/financial-summary) — gross receipts to date, configured prize exposure, and a deliberately-labellednetReceiptsProvisionalwith the Module 11/12 boundaries carried on the response. -
Unpaid work queue —
?paymentStatus=UNPAID|PARTIALon the existing order list, not a new endpoint. -
New error codes
CARD_DATA_REJECTED,PAYMENT_EXCEEDS_ORDER_TOTAL,ORDER_NOT_PAYABLE,PAYMENT_ALREADY_REVERSED,REFUND_NOT_ELIGIBLE; one new event typePAYMENT_REJECTED(a refused payment leaves no row, so without it the refusal would be invisible to interrogation — andCARD_DATA_DETECTEDis a security fact a GLI lab looks for by filter). -
cfoilgainsraffles payments record\|reverse\|ledger\|list,raffles refunds request\|approve\|reject\|list,raffles reconciliation, andraffles financial-summary.
Changed
-
raffle_ordersgainsamount_paid,payment_status,paid_on/paid_at,refunded_on/refunded_atandpayment_status_changed_at— a cache of the ledger, audited against it, so Module 8’s counterfoil batches and Module 9’s pool queries need one indexed predicate rather than an aggregate per order. Order responses now carryamountPaid,paymentStatusandticketsEligible. -
VoidReasongainsREFUND(appended, never renaming an existing value). It is not selectable on an individual void request — the only way to reach it is the refund control, which is what makes "these voids were a refund" a reliable inference at audit. -
ticket_void_requestgains a nullablerefund_request_id. -
Overpayment is rejected (
PAYMENT_EXCEEDS_ORDER_TOTAL): money above the order total is not a gambling receipt and recording it here would corrupt the R18 breakout at its source.
Fixed
-
Lost update on concurrent payments. The order row lock serialized two operators correctly, but the balance was then recomputed by a non-locking
SUMserved from an MVCC snapshot fixed before the lock was granted — so both saw a zero starting balance and a $100 order settled by two $50 payments was recorded as $50 paid, leaving the purchaser’s tickets permanently ineligible. The money write paths now run atREAD_COMMITTED, so the recomputation sees everything committed before the lock. Found byPaymentLedgerIT. -
A payment could be reversed more than once, double-subtracting and driving the balance negative.
PAYMENT_ALREADY_REVERSEDnow covers "the target already has a reversal", not only "the target is itself a correction", as the spec defines it.
Deployment
-
Migration
V6__payment_recording.sqlis additive and forward-only. Existing orders default topayment_status = UNPAID— factually correct, but a raffle already selling under the 0.5.0 build shows every order unpaid until Finance records the historical payments with their correctpayment_date. That backfill must be scheduled with the operating org before this release, not after. No pool is harmed today (Module 9 does not exist yet), but the reconciliation report and the work queue are wrong until it completes. -
Rollback to 0.5.0 against a
V6schema is safe and lossless — the older build ignores the new tables and columns, andOrderStatus.VOIDEDalready exists in its enum. What is lost is the control, not the data: unpaid orders stop being distinguishable.
C.12. [0.5.0] — 2026-07-25
C.12.1. Module 5 — Order Intake (In-Person / Mail / Fax / Phone)
The sales-entry API: the one versioned surface through which every order enters the system, whatever channel it arrived on. Spec: the Module 5 tech spec. Traceability: R1 (four legal channels only), R3 + R18 (locked pricing, discounted per-ticket rate recorded), R8 (18+ attestation), R10/R13 (draw numbers, via Module 4), P1 (Washington residents only).
Added
-
Order completion —
POST /raffles/{id}/ordersvalidates channel, sales window, residency, attestation and pricing, resolves or creates the purchaser, writes the order and its lines, and mints its draw numbers throughDrawNumberIssuancePortin one transaction. An order and its numbers are born together or not at all: any failure rolls back the order, its lines, the purchaser, the series provisioning and the counter advance together. There is no draft order and no reservation. -
Replay safety — a required client-generated
clientRequestId, unique per sales unit (UNIQUE (sales_unit_id, client_request_id)). A retried submit returns200 OKwith the original order rather than minting a second irreversible range; concurrent duplicates are arbitrated by the unique key, withOrderPlacementCoordinatorreturning the winner’s order to the losers. -
Server-side pricing (R3, R18) — the request names a
pricingPlanIdand aquantity; no request DTO in the module has a price or amount field. Eachraffle_order_linesrow snapshots the plan’s label, ticket count, price and per-ticket rate, so the R18 discounted rate is recorded against the sale and Module 12 can reproduce a report from stored data alone. -
Residency policy (P1) —
ResidencyPolicywith the v1WashingtonResidencyPolicy(stateWA, postal code 98001–99403), rejecting withRULE_VIOLATION/rule: "P1". A code seam, not a YAML toggle, on the same principle asRegulatoryLimits; L6 swaps the implementation. Re-run on purchaser correction. The postal-code range is a transcription guard, not evidence. -
18+ attestation (R8) — refused with
ATTESTATION_REQUIRED; the attesting operator’s id and timestamp are stored on the order, because the record is the evidence, not the code path. -
Sales-window enforcement — orders are accepted only in
[salesOpenAt, salesCloseAt), read from the raffle row heldFOR UPDATEso a concurrent DRAFT edit cannot move the window under an in-flight sale. -
Duplicate-purchaser prompt — a normalized name + postal-code match returns
409 PURCHASER_MATCH_FOUNDwith candidates, resolved in one further round-trip by eitherpurchaser.purchaserId(reuse) oracknowledgeDuplicate: true(which stampsduplicateAckOfId, the evidence the prompt was answered). A prompt with a documented override, not a block, and enforced server-side so API-only clients inherit it. -
RSU-readiness —
sales_unitsregistry (/sales-units, System Administrator) and asales_unit_idon every order.WORKSTATIONis the only type registrable in v1;RSU(L2) andVENDOR_FEED(L1) are reserved so those phases add rows, not a migration. The order API is the only creation path — the UI is a client of it, not a privileged route beside it. -
Advisory preview —
POST /raffles/{id}/orders/previewreturns totals, per-line R18 rates, sales-window state and duplicate candidates, taking no lock and writing nothing. -
Raffle-scoped purchasers —
/raffles/{id}/purchaserssearch, read and correction. Scoping purchasers to the raffle keeps per-raffle retention (R12) and the Module 12 archive clean cuts. -
Order corrections —
PATCHexposessourceReferenceandnoteonly. Changing what was sold goes through Module 4’s two-person void, not an edit. -
ORDER_REJECTEDsignificant event — committed inREQUIRES_NEWso the audit trail of a refused sale survives the rejected transaction’s rollback, withreasonone ofSALES_WINDOW_CLOSED,RESIDENCY,ATTESTATION,INVENTORY_EXHAUSTED,LICENSE_REQUIRED. -
PII masking for purchaser data —
PiiMasker.maskName/maskPhonejoin the existingmaskEmail; event summaries carry masked identifiers plus city/state, never a street line or full postal code. -
New error codes
ATTESTATION_REQUIRED,SALES_WINDOW_CLOSED,SALES_UNIT_UNKNOWN,PURCHASER_MATCH_FOUND. -
Migration
V5__order_intake.sql:sales_units,purchasers,raffle_orders,raffle_order_lines, plus theticket.order_id→raffle_ordersforeign key Module 4 deferred to this release.
Changed
-
ticket.order_idis now a real foreign key. A synthetic order id is no longer insertable — issuance called outside an order must passnull, asDrawNumberIssuancePortdocuments. -
Module 5’s tables are named in the plural (
sales_units,purchasers,raffle_orders,raffle_order_lines), matchingusers; Modules 3 and 4 remain singular. The split is flagged in the tech spec for ratification rather than silently reconciled. -
RoleSegregationArchitectureTestallowlists/sales-unitsas a system operation, with the rationale recorded in the test. Orders and purchasers stay subject to the segregation guard.
Notes
-
Payments are not recorded here (Module 6): an order carries
totalAmountbut no payment state, and ticket eligibility on payment arrives with that module. -
orderReferenceis derived asORD-{id}— stable, but not gapless, and not a sales count.
C.13. [0.4.0] — 2026-07-19
C.13.1. Module 4 — Ticket Inventory & Draw Number Issuance
The integrity core: the single source of draw numbers, issued consecutively, gaplessly, and uniquely per raffle, with clearly identified voids. Spec: the Module 4 tech spec. Traceability: R10 (consecutive numbering), R13 (no duplicate draw numbers; voids clearly identifiable), R5 (the pre-sale lock trigger, co-located with issuance).
Added
-
Draw-number issuance —
DrawNumberIssuancePort.issueTickets, the internal seam Module 5 calls inside the order-completion transaction. Numbers are allocated from an in-transaction counter (ticket_series.next_draw_number) advanced underSELECT … FOR UPDATE, so the sequence is gapless by construction (a rolled-back order reverts the counter) — deliberately notAUTO_INCREMENT, which gaps on rollback. Atomic all-or-none. There is no HTTP mint endpoint — a draw number is born only at order completion, mirroring Module 3’s no-manual-lock rule. -
The R13 no-duplicate guarantee at the database:
UNIQUE (series_id, draw_number), enforced by the schema, not application logic alone; theFOR UPDATEserialization means it is never hit in practice. -
The pre-sale lock trigger, co-located with issuance (R5):
issueTicketscallsRaffleConfigLockPort.lockForFirstIssuanceas its first step — the first ticket issued is the lock trigger, so no number can be minted against an unlocked, unlicensed, or over-subscribed raffle (LICENSE_REQUIRED/INVENTORY_EXHAUSTEDroll the order back). -
Multi-series model —
ticket_series(named series, distinct prefixes, independent consecutive sequences). v1 auto-provisions exactly one activeENHANCEDseries per raffle (TicketSeriesProvisioner, idempotent, sized fromraffle.total_ticket_count).REFER_A_FRIENDis a reserved, non-provisionable purpose so the L4 series lands with no schema or numbering change. -
Two-person void control (R13):
POST /api/v1/raffles/{id}/tickets/{ticketId}/void-requests(Sales Operator or higher) opens a request with a controlled reason plus free text; a different Raffle Administrator or Finance/Records Officer approves or rejects it (/api/v1/raffles/{id}/void-requests/{requestId}/approve|reject). Only an approval flips the ticket toVOID— the number is kept, never freed, and remains permanently visible in inventory and reports.GET /api/v1/raffles/{id}/void-requestsis the work queue.approver ≠ requesteris enforced in the service, beyond the role gate. -
Reconciling inventory —
GET /api/v1/raffles/{id}/ticket-inventory: per-series and totals withunissued + issued + voided = totaland an explicitreconcilesassertion, computed in one snapshot. A raffle that has never sold reports an all-unissued view (reads never provision). -
Ticket reads —
GET /api/v1/raffles/{id}/tickets(paged; filter bystatus,seriesCode,voidPending) andGET /api/v1/raffles/{id}/tickets/{drawNumber}(the scan/keying lookup Modules 8/10 reuse). Reads open to all five roles (the Auditor must read inventory); void writes excludeAUDITORandSYSTEM_ADMINISTRATOR(segregation of duties). -
New error codes:
INVENTORY_EXHAUSTED,TICKET_NOT_VOIDABLE,VOID_REQUEST_NOT_PENDING,VOID_APPROVAL_SELF. -
Flyway migration
V4__ticket_inventory:ticket_series,ticket,ticket_void_request(additive, forward-only; theticket.order_idFK arrives with Module 5). -
Test coverage across all three tiers, including the 1,000-tickets / 20-parallel-sessions concurrency test (zero duplicates, zero gaps), the counter’s revert-on-rollback, the DB-level duplicate-draw-number rejection, the concurrent double-approve serialized to exactly one void, and an architecture test proving issuance is port-only (no mint endpoint).
-
cfoilreference CLI (0.4.0):raffles tickets {inventory,list,get}andraffles void-requests {create,approve,reject,list}. No issuance command — issuance is port-only.
Changed
-
Module 4 fires the existing
RECORD_CREATE(issuance, carrying the draw-number range),RECORD_VOID(an approved void, carrying both the requester and the approver), andRECORD_MODIFY(a void-request rejection) event types — no new event types. The first sale continues to fire Module 3’sCONFIGURATION_LOCK, now triggered by this module’s issuance.
Notes
-
A pending void does not change ticket eligibility; only an approved
VOIDexcludes a ticket from drawing pools (Module 9 keys onstatus = VOID). Pending requests are surfaced asvoidPendingin inventory so they are never invisible. -
The
VoidReasoncontrolled list (DATA_ENTRY_ERROR,DUPLICATE_ORDER,WRONG_PRICING_PLAN,PAYMENT_NOT_COMPLETED,PURCHASER_REQUEST,OTHER) is a proposed starter set pending compliance-lead ratification; the values are stable identifiers once chosen.
C.14. [0.3.0] — 2026-07-16
C.14.1. Module 3 — Raffle & Drawing Configuration (with Pre-Sale Lock)
Models the enhanced raffle’s legal parameters — the raffle, pricing plans, drawings, prizes, and the local gambling-tax rate — with all eligibility standards fixed irreversibly before the first ticket is sold. Spec: the Module 3 tech spec. Traceability: R3 (≤ $250/ticket), R4 (≤ $10M grand prize), R5 (standards fixed before first sale), R10 (disclosures), R18 (bundle pricing + recorded discounted per-ticket rate), R19 (local tax rate ≤ 5%), L4 reservation.
Added
-
Raffle configuration API (
/api/v1/raffles): create, list, fetch (full config view with pricing plans, drawings, and prizes;?includeRemoved=trueshows soft-removed children), and DRAFT-only core edits. Reads open to all five roles (raffle parameters are public ticket disclosures); writes are Raffle Administrator only (segregation of duties). -
Pricing plans (single-ticket and multi-ticket bundles, WAC 230-11-085/-086): R3 enforced with exact arithmetic (
price ≤ $250 × ticketCount), and the discounted per-ticket rate derived and recorded at save (scale 4, e.g. 250/3 →83.3333) for R18 reporting. At most one active plan per ticket count. -
Drawings with typed eligibility parameters (R5):
GRAND(exactly one active; no cutoff — its pool is structurally the sales close; scheduled at or after sales close),EARLY_BIRD(cutoff within the sales window),MULTIPLE_TICKET(cutoff + threshold ≥ 2).REFER_A_FRIENDis reserved non-selectable (L4) — schema and enum exist so the later module lands without migration. -
Fixed-value prizes with the R4 grand-prize cap enforced as an exact sum over the grand drawing’s active prizes.
PERCENTAGE_OF_RECEIPTSis reserved non-selectable (stakeholder decision: v1 is fixed-value only); its columns and the R10 minimum-disclosure validation ship dormant so enabling it later is a validation change, not a migration. -
The pre-sale lock (R5):
RaffleConfigLockPort.lockForFirstIssuance— the internal seam Module 5 will call inside the first ticket-issuance transaction. One-wayDRAFT → LOCKEDvia an atomic conditional UPDATE (idempotent under concurrent first sales; exactly oneCONFIGURATION_LOCKevent); every configuration mutation serializes against it through aSELECT … FOR UPDATEguard. There is no unlock and no manual-lock endpoint; post-lock mutation attempts return409 CONFIGURATION_LOCKEDand are audited. A raffle cannot lock — cannot sell its first ticket — until its WSGC license number is recorded (LICENSE_REQUIRED). -
The lock exception:
PUT /api/v1/raffles/{id}/tax-rate— the local tax rate is a reporting parameter, not an eligibility standard, and stays editable post-lock (every change evidenced) until Module 12 finalizes the tax worksheet (tax_rate_finalized_atseam shipped now). -
Tax jurisdiction lookup (R19): reference table seeded with King County, read-only suggestion endpoint (
/api/v1/tax-jurisdictions[/suggestion]) resolving city → county → state; the raffle snapshots jurisdiction, suggested rate, effective rate, and whether it was overridden. Rates above 5% (0.0500) are rejected. Maintenance endpoints arrive with Module 12. -
Pre-lock compliance checklist (
GET /api/v1/raffles/{id}/compliance-checklist): pass/fail line items for R3, R4, R5, R10, R18, and the license gate — computed with the same validators as save-time checks, so the two can never disagree. -
New error codes:
RULE_VIOLATION(witherror.rule+error.details.citation),CONFIGURATION_LOCKED,FEATURE_NOT_AVAILABLE,TAX_RATE_FINALIZED,LICENSE_REQUIRED. -
New significant-event type
CONFIGURATION_LOCKED_ATTEMPT— post-lock tamper attempts are a one-filter interrogation query, committed independently (REQUIRES_NEW) so the audit trail survives the rejected mutation’s rollback. -
Configuration:
counterfoil.raffle.default-time-zone(defaultAmerica/Los_Angeles, used to render drawing date/time disclosures venue-local). -
Flyway migration
V3__raffle_config:raffle,pricing_plan,drawing,drawing_prize,tax_jurisdiction(additive, forward-only). -
Test coverage across all three tiers, including the 12-caller lock race (exactly one winner, one event), the edit-vs-lock race, the reflection-driven post-lock rejection matrix, and a source-scan/reflection arch test proving no code path can move a raffle back to
DRAFT.
Changed
-
Module 3 begins firing the
CONFIGURATION_CHANGE/CONFIGURATION_LOCKevent types Module 2 declared, plusRECORD_CREATEfor configuration records — all through the unchangedSignificantEventPort. -
Regulatory limits ($250 ticket, $10M grand prize, 5% tax) are compiled constants with citations (
RegulatoryLimits), deliberately not configuration.
Notes
-
The King County seed rate is the RCW 9.46.110 statutory 5% ceiling pending the compliance lead’s verified ordinance value (PRD dependency); the per-raffle override means an imprecise suggestion never blocks configuration. Swap the seed value when compliance supplies it.
-
Draft-stage removals are soft (
status = REMOVED) — configuration records are never hard-deleted, consistent with the retention invariant (R12).
C.15. [0.2.0] — 2026-07-15
C.15.1. Module 2 — Significant Event Log & Interrogation (Audit Backbone)
Turns Module 1’s interim event table into a tamper-evident, searchable audit backbone.
Implemented behind the existing SignificantEventPort, so no Module 1 caller changed.
Spec: the Module 2 tech spec.
Traceability: R14 (significant-event logging + interrogation), R15 (integrity via hashing),
R16 (authoritative clock), GLI-31 §5.6.
Added
-
Cryptographic hash chain over the significant-event log: every event carries the SHA-256 hash of the prior event, so any edit, deletion, or reorder is detectable. Events are chained in-transaction with the change they describe (
ChainedEventWriterbuffers per transaction and flushes at commit;EventChainAppenderserializes appends on a singletonchain_headrow for a gap-free sequence and a deadlock-free lock order). -
Chain verification (
ChainVerificationService): keyset-walks the chain recomputing every link, runs on a nightly schedule, on application startup, and on demand, and raises its ownEVENT_CHAIN_VERIFIED/EVENT_CHAIN_BROKENsignificant event. -
Interrogation search —
GET /api/v1/events: filter by date range, event type(s), actor, and affected entity, paged, index-led (R14). -
Verification API —
POST /api/v1/events/verification(inline200, or202with a run id for large chains; System Administrator only) andGET /api/v1/events/verification/latest. -
Full GLI-31 §5.6 event-type catalog declared in
SignificantEventType(record void, configuration change/lock, drawing opened/closed, winner recorded, report/export, backup, restore, clock adjustment, software verification, system startup, chain verified/broken). -
Append-only enforcement at the database via
BEFORE UPDATE/DELETEreject triggers (best-effort: installed where the deploy user has the privilege; the hash chain and the absence of any update/delete code path remain the primary guarantee otherwise). -
SYSTEM_STARTUPsignificant event emitted on application readiness. -
Configuration:
counterfoil.events.verification.{cron,on-startup,inline-max-events}andcounterfoil.events.search.max-page-size.
Changed
-
Flyway migration
V2__event_chainextendssignificant_eventwith the chain columns, adds thesignificant_event_chain_headandevent_chain_verificationtables plus interrogation indexes, and seals pre-existing Module 1 rows into the chain (backfill shares the runtime canonicalizer so sealed rows verify byte-for-byte). Forward-only and additive. -
POST /api/v1/events/verificationis a System-Administrator system operation, not an Auditor action: running a verification appends a chain event (a state change), which an Auditor must never cause. Auditors read outcomes viaverification/latestand the scheduled runs.
Notes
-
Deploy V2 and the Module 2 build together: once the chain columns become
NOT NULL, the Module 1 build can no longer write events (expected forward-only posture).
C.16. [0.1.0] — 2026-07-13
C.16.1. Module 1 — Identity, Roles, and Access Control
The security kernel every later module depends on. Spec: the Module 1 tech spec. Traceability: R12, R14 (groundwork), R15, R16.
Added
-
Authentication with opaque, server-side session tokens (login, logout,
me, self-service password change) over a JAX-RS (Jersey) request pipeline; Argon2id password hashing. -
Role-based authorization via
@RolesAllowedwith a default-deny posture and the fixed five-role catalog (Raffle Administrator, Sales Operator, Finance/Records Officer, Auditor, System Administrator). -
User administration (
/api/v1/users): create, list, fetch, update, unlock, reset password, disable — System Administrator only. Users are never hard-deleted; retirement isdisable(R12). -
Account lockout after 5 consecutive failed logins, enforced by an atomic conditional UPDATE that is correct under concurrency; per-IP login burst limiting.
-
Record attribution: every persisted
AuditableEntityis stamped with the authenticated actor and an authoritative-Clocktimestamp; a write with no principal fails closed (no unattributed records). -
SignificantEventPortseam with a minimal durable, append-only event writer (superseded by Module 2), and the significant-event categories for authentication and record mutation. -
First-boot bootstrap of a single System Administrator from a JSON file, guarded to run only when no users exist.
-
Consolidated REST API contract (see Appendix B).
Security
-
Passwords are stored only as Argon2id hashes and never logged or serialized; PII is masked in event summaries. Password policy is v1-minimal (≥ 8 characters); complexity and breached-password checks are a required pre-production / pre-certification gate.
C.17. [0.0.0] — 2026-07-11
C.17.1. Added
-
Project scaffolding and the Counterfoil v1.0 product requirements document (the PRD): the 13-module breakdown, actors, and regulatory constraints R1–R19 / P1 for Washington State enhanced raffles, built to be certifiable against GLI-31 v1.1.
Appendix D: Software dependencies & runtimes
Everything Counterfoil is built from or runs on, with exact versions. The Libraries table is
checked against the build on every gradle check by verifyDependencyInventory: adding, removing or
upgrading a library, Gradle plugin or build tool without updating its row here fails the build.
Scope is what a certification lab and the Module 13 SBOM care about:
-
runtime— the library is on the runtime classpath of a shipped artifact: the server boot jar (counterfoil.jar), thecfoildistribution, or thearchive-formatjar both of them carry. A runtime library is a Module 13 SBOM and signature item. -
build-time— used only to build, test, format or document Counterfoil, and shipped in no artifact: test libraries, Gradle plugins, the formatter and the documentation toolchain.
Module(s) names the Gradle module that declares it. server is the root project. Spring Boot
starters are listed as declared; the libraries they bring in transitively are governed by the
Spring Boot BOM and are enumerated by the Module 13 SBOM, not here. Licences are
SPDX identifiers where one exists.
D.1. Libraries
| Coordinate | Version | Licence | Module(s) | Scope |
|---|---|---|---|---|
|
2.21.4 |
Apache-2.0 |
cli |
runtime |
|
2.21.4 |
Apache-2.0 |
cli |
runtime |
|
9.7.0 |
GPL-2.0-only WITH Universal-FOSS-exception-1.0 |
server |
runtime |
|
1.16 |
BSD-3-Clause |
server |
runtime |
|
4.7.6 |
Apache-2.0 |
cli |
runtime |
|
3.0.8 |
Apache-2.0 |
server |
runtime |
|
1.78.1 |
Bouncy Castle Licence (MIT-style) |
server, archive-format |
runtime |
|
12.10.0 |
Apache-2.0 |
server |
runtime |
|
4.1.0 |
Apache-2.0 |
server |
runtime |
|
4.1.0 |
Apache-2.0 |
server |
runtime |
|
4.1.0 |
Apache-2.0 |
server |
runtime |
|
4.1.0 |
Apache-2.0 |
server |
runtime |
|
4.1.0 |
Apache-2.0 |
server |
runtime |
|
7.1.0 |
Apache-2.0 |
server |
runtime |
|
8.9.0 |
Apache-2.0 |
server |
build-time |
|
1.36.1 |
Apache-2.0 |
server, cli, archive-format |
build-time |
|
4.7.6 |
Apache-2.0 |
cli |
build-time |
|
1.1.6 |
Apache-2.0 |
server |
build-time |
|
4.0.4 |
Apache-2.0 |
server |
build-time |
|
4.0.4 |
Apache-2.0 |
server |
build-time |
|
3.0.0 |
Apache-2.0 |
server |
build-time |
|
2.3.19 |
MIT |
server |
build-time |
|
3.26.3 |
Apache-2.0 |
cli, archive-format |
build-time |
|
9.4.13.0 |
EPL-2.0 OR GPL-2.0 OR LGPL-2.1 |
server |
build-time |
|
1.21.1 |
MIT |
server |
build-time |
|
5.11.4 |
EPL-2.0 |
cli, archive-format |
build-time |
|
1.11.4 |
EPL-2.0 |
cli, archive-format |
build-time |
|
5.11.4 |
EPL-2.0 |
cli, archive-format |
build-time |
|
4.1.0 |
Apache-2.0 |
server |
build-time |
|
4.1.0 |
Apache-2.0 |
server |
build-time |
|
4.1.0 |
Apache-2.0 |
server |
build-time |
|
2.0.5 |
MIT |
server |
build-time |
|
2.0.5 |
MIT |
server |
build-time |
D.2. Runtimes
| Runtime | Version | Used for |
|---|---|---|
Java (JDK) |
25 |
Builds and runs the server, |
MySQL |
9.7 |
The server’s database. |
Gradle |
9.4.1 |
Build only. There is no committed wrapper; use the system |
Google Chrome (desktop) |
Current stable release |
The administration web interface at |
Mozilla Firefox (desktop) |
Current stable release |
The administration web interface at |