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, ThreadLocalRandom and shuffle from 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

RAFFLE_ADMINISTRATOR

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)

SALES_OPERATOR

Order entry, receipt issuance, raising void requests

Change configuration, approve their own void, touch pools, barrels or drawings

FINANCE_RECORDS_OFFICER

Payments, refunds, reconciliation, prize records, reserve balances, winner fulfilment, records, reports, archives, destruction requests

Configure a raffle or run a drawing

AUDITOR

Reading everything — records, logs, reports, exports

Anything that writes. Enforced by a test that exercises every mutating endpoint under this role and asserts rejection

SYSTEM_ADMINISTRATOR

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

CHECK (witnessed_by <> awarded_by) in the database, plus UNIQUE (session_id, drawing_prize_id)

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

ACCOUNT_LOCKOUT

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.

BACKUP_FAILURE

(Module 13) A scheduled backup fails, or its verification does.

BACKUP_SUCCESS

(Module 13) A scheduled backup completes and its test-restore or checksum validation passes.

CLOCK_ADJUSTMENT

(Module 13) The system clock is adjusted manually, or drifts past the configured threshold (R16).

CONFIGURATION_CHANGE

A raffle, pricing plan, drawing or prize is created or edited while still DRAFT; a tax-jurisdiction rate is appended or corrected; or — at startup — organization.json or records.json differs from the hash last recorded. That startup path is how a file-based profile keeps an edit history no role can alter.

CONFIGURATION_LOCK

The first ticket is issued and the raffle moves DRAFT → LOCKED. Exactly one per raffle, irreversible, and triggered by issuance rather than by any endpoint.

CONFIGURATION_LOCKED_ATTEMPT

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.

DRAWING_CLOSED

A drawing session closes — in the same transaction that generates the Raffle Drawing Report, so there is never one without the other.

DRAWING_OPENED

A drawing session opens. Carries the frozen gate result, including the R11 reserve position and any override reason.

EVENT_CHAIN_BROKEN

Chain verification finds a break. The verifier raises this about the log it is verifying — which is why the chain check is itself auditable.

EVENT_CHAIN_VERIFIED

Chain verification completes intact, at startup and on the nightly schedule.

EXPORT_PRODUCED

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.

LOGIN_FAILURE

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.

LOGIN_SUCCESS

A session is created.

ORDER_REJECTED

An order is refused — SALES_WINDOW_CLOSED, RESIDENCY, ATTESTATION, INVENTORY_EXHAUSTED, LICENSE_REQUIRED or ORGANIZATION_NOT_CONFIGURED. Own transaction, for the same reason as the locked-attempt event: a refused sale must still leave a record.

PAYMENT_REJECTED

A payment is refused, including CARD_DATA_DETECTED. Own transaction — a refused payment leaves no ledger row, so without this the refusal would be invisible to interrogation, and a card-data rejection is exactly what a GLI lab looks for by filter.

PERMISSION_DENIED

An authenticated user is refused by role. Emitted from the shared forbidden-response mapper, so it covers every endpoint automatically, including ones added later.

RECEIPT_DELIVERY_FAILED

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.

RECORD_CREATE

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.

RECORD_MODIFY

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.

RECORD_VOID

An approved ticket void. A pending request does not emit it; the approval does, carrying both the requester and the approver.

RECORDS_DESTROYED

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.

REPORT_GENERATED

A Raffle Drawing Report at session close; a counterfoil manifest or a manifest-vs-pool verification (an unbalanced one is this event carrying balanced: false, not a new type); a reconciliation report; a pool determinism verdict; a tax-worksheet finalization.

RESTORE_PERFORMED

(Module 13) A restore is performed; the restored system is expected to pass chain verification.

SOFTWARE_VERIFICATION_FAIL

(Module 13) Deployed artefacts do not match the release signatures. Will block entry into any drawing session (R16).

SOFTWARE_VERIFICATION_PASS

(Module 13) Deployed artefacts match the signed, released build, on startup or on demand.

SYSTEM_STARTUP

The server boots, before chain verification runs.

WINNER_RECORDED

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?

organization.json

COUNTERFOIL_ORGANIZATION_FILE

The whole product. No licensee profile ⇒ no raffle can issue its first ticket ⇒ nothing can be sold

Yes — read on every boot

records.json

COUNTERFOIL_RECORDS_FILE

Filing, archiving and destroying — and nothing else. Ticket sales are unaffected by its absence

Yes — read on every boot

bootstrap admin

COUNTERFOIL_BOOTSTRAP_FILE

Seeds the first System Administrator when users is empty

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

GRAND_V1

GRAND

Every paid, non-void ticket ordered on or before sales close

EARLY_BIRD_V1

EARLY_BIRD

The same, bounded by the drawing’s own cutoff. Winning removes nothing (R6)

MULTIPLE_TICKET_ALL_TICKETS_V1

MULTIPLE_TICKET

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

inPoolNotPrinted

Tickets sold since you printed. Generate a supplement batch

printedNotInPool

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

PASSED

The check ran; recorded unencumbered balance ≥ the day’s cash prize obligation

FAILED

The check ran; the funds are short

NOT_AVAILABLE

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

  1. cfoil pools verify --raffle 7 --drawing 19 — deterministic.

  2. cfoil prize-records missing --raffle 7 — empty.

  3. cfoil reserve check --raffle 7 --drawing 19 — PASSED, or you know exactly why not.

T-minus one day

  1. cfoil counterfoils verify --raffle 7 --drawing 19 — coverage complete, inPoolNotPrinted empty.

  2. Record a fresh reserve balance if the last observation is approaching 7 days old — a stale one answers NOT_AVAILABLE, not PASSED.

  3. 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?

POOL_NOT_CLOSED — the pool is still open

No

POOL_EMPTY — nothing eligible

No

SESSION_ALREADY_OPEN — one open session per drawing

No

DRAWING_NOT_READY — a batch is unconfirmed, print coverage is incomplete, or an eligible member has no printed stub

No

WITHDRAWN_STUB_UNACKNOWLEDGED — printed stubs have since lost standing

No

PRIZE_RECORD_REQUIRED / PRIZE_DOCUMENTATION_REQUIRED / ALL_PRIZES_AWARDED

No

RESERVE_OVERRIDE_REQUIRED — the R11 check did not pass

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

cfoil records set

WSGC records inspection (R12)

Content hash; blocked if it does not reconcile

Local gambling-tax worksheet

cfoil tax show

Your city/county (R19)

Byte-identical once finalized

Raffle Drawing Report

cfoil drawings report

WSGC, GLI lab (R17)

Byte-identical, immutable, one per closed session

Prize-and-winner register

cfoil winners register

WSGC winners recordkeeping (R12)

Content hash over the figures alone

Counterfoil manifest

cfoil counterfoils manifest

WSGC, physical-pool completeness (R12/R13)

Byte-identical; checksum over ordered members

Ticket inventory

cfoil raffles tickets inventory

WSGC unsold/void listing (R12)

Reconciles at any point in time

Reconciliation & financial summary

cfoil raffles reconciliation

Internal + WSGC (R18)

Fails rather than returns unbalanced

Significant-event interrogation

cfoil events search

GLI lab (R14, §5.6)

Append-only, hash-chained

Encrypted raffle archive

cfoil archive export

Your records custodian (R12)

Offline-verifiable, manifest of checksums

Destruction certificate

cfoil destruction cert

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

GROSS_RECEIPTS_BREAKOUT

The per-plan R18 breakout sums to total gross receipts

NET_RECEIPTS

Gross − prizes paid = net

AMOUNT_PAID_CACHE_DRIFT

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

TICKETS_PAID / TICKETS_PAID_EXCEED_ISSUED

Paid ticket counts agree with the ledger and never exceed issuance

SERIES_INVENTORY / CONFIGURED_TOTAL

Unissued + issued + voided = the configured total, per series and overall

POOL_NOT_CLOSED / POOL_NOT_DETERMINISTIC

Every drawn pool is closed, and still recomputes to what was frozen

BATCH_OVERLAP / BATCH_HASH_MISSING

Active batches are disjoint and every manifest carries its checksum

DRAWING_REPORT_MISSING

Every closed session has exactly one report

WINNERS_DISAGREE

The winners in the reports are the winners in the register

PRIZE_VALUE_MISMATCH

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."

cfoil records set --raffle 7 --format pdf

"How was the eligible pool for the early bird decided?"

cfoil pools show --raffle 7 --drawing 18 — the rule in plain English, generated from the locked configuration

"Prove the barrel was complete."

cfoil counterfoils manifest + cfoil counterfoils verify

"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?"

cfoil events search --entity-type ticket --entity-id …, plus cfoil raffles tickets get

"Who voided these and who approved it?"

The void records; requester and approver are separate people by construction

"Show me the unsold tickets."

cfoil raffles tickets inventory — reconciles exactly

"Where are your records kept?"

cfoil records-profile show, and the archive at your custodian’s Washington location

3.6. Evidence for a GLI lab (GLI-31 v1.1)

  • §5.6 interrogation — cfoil events search filters by any combination of date/time range, event type, user, entity type and entity id.

  • Chain integrity — cfoil events verify re-links the whole chain; a break raises EVENT_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

RAFFLE_RECORDS

3 years from the fiscal-year end in records.json

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: 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:

  1. 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.

  2. 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.

  3. 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 reconciliation against the bank, cfoil raffles tickets inventory reconciles, cfoil events verify.

  • Before each drawing: the Phase 8 runbook.

  • After the last drawing: record every fulfilment, then cfoil records reconcile until 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

ERROR

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.

WARN

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.

INFO

A record-bearing state transition, or a lifecycle fact.

Nothing. This is the narrative: orders completed, payments recorded, pools closed, winners recorded, reports generated.

DEBUG

The decision behind a transition — inputs, intermediate values, the branch taken.

Off in production. Turn on for one area when reproducing a specific problem.

TRACE

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 org.hibernate.orm.jdbc.bind to TRACE on an instance holding real purchaser data. It prints every bound statement parameter — names, addresses and phone numbers — into the operational log, where no retention or destruction path can reach them. It is pinned to WARN in application.yml and deliberately has no environment variable.

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 (cfoil users list), create (create), edit email, name and roles (update), unlock (unlock), reset a password (reset-password), disable (disable). There is no delete.

Sales units

System Administrator

List, register a workstation, retire one (cfoil sales-units list, create, retire).

Tax jurisdictions

System Administrator

List, add a rate row, correct a row not yet in effect (cfoil tax-jurisdictions list, add, correct).

Event chain

System Administrator

Run a verification and read the latest result (cfoil events verify, cfoil events verification). A long chain verifies in the background; reload the page to see the result.

Destruction approvals

System Administrator, Raffle Administrator

Approve or reject a pending destruction request with a required note (cfoil destruction list, approve --approve, approve --reject, each with --note). A raffle’s page links to its own requests.

Event log

System Administrator, Raffle Administrator

Search the significant-event log by time, event type, actor and entity, paged (cfoil events search). Each result’s actor and entity is a link that narrows the search to it.

Raffles

Raffle Administrator

List and open raffles with their compliance checklist (cfoil raffles list, get, checklist); create a raffle and edit a draft’s core fields and time zone (create, update); set the tax rate, also after the lock until the worksheet is finalized (tax-rate); add, edit and remove pricing plans, drawings and prizes on a draft (cfoil raffles pricing-plans, drawings, prizes with add, update, remove). The list shows each raffle’s pending void, refund and destruction requests, and a locked raffle’s page has an Oversight panel linking to each queue.

Void approvals

Raffle Administrator

A locked raffle’s void-request queue, pending by default: approve, reject or withdraw, each with a required note (cfoil raffles void-requests list, approve, reject, each with --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 (cfoil raffles refunds list, approve, reject, each with --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 (cfoil raffles tickets inventory, list, get). Read-only.

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

2027-11-30 00:00

2027-11-30 01:00

2027-11-30T08:00:00Z

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:

  1. Tomcat terminates TLS, configured with server.ssl.bundle (or server.ssl.*) in /var/lib/counterfoil/config/application.properties. A plain-HTTP request to port 9143 then fails the TLS handshake.

  2. A reverse proxy terminates TLS (nginx, Apache) and forwards plain HTTP to port 9143. The server reads X-Forwarded-Proto and X-Forwarded-For by default (COUNTERFOIL_FORWARD_HEADERS_STRATEGY is native), 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 narrow server.tomcat.remoteip.internal-proxies to 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

counterfoil.argon2.hash-length

32

Password hash length in bytes.

counterfoil.argon2.iterations

3

Argon2id time cost for password hashing.

counterfoil.argon2.memory

65536

Argon2id memory cost in KiB (64 MiB).

counterfoil.argon2.parallelism

1

Argon2id lanes.

counterfoil.argon2.salt-length

16

Password salt length in bytes.

counterfoil.auth.ip-rate-limit.max-attempts

10

Sign-in attempts allowed per source address per window. Also applies to in-band witness verification.

counterfoil.auth.ip-rate-limit.window

1m

The rate-limit window.

counterfoil.auth.lockout-threshold

5

Consecutive failures before an account locks. Fires at exactly this count.

counterfoil.bootstrap.file

${COUNTERFOIL_BOOTSTRAP_FILE:}

First-boot System Administrator seed. Delete after first boot.

counterfoil.counterfoils.generation.max-members

200000

Runaway guard on one batch. Never a business cap — a batch must equal the eligible pool.

counterfoil.counterfoils.print.cut-every

1

Partial cut every N stubs. Raise for perforated stock.

counterfoil.counterfoils.print.default-profile

ESCPOS_80

ESCPOS_80 | ESCPOS_58 | PDF_LETTER | PDF_A4.

counterfoil.counterfoils.print.expiry-sweep-interval

15m

How often forgotten print runs are auto-abandoned.

counterfoil.counterfoils.print.preview-max-count

3

Stubs in an alignment preview — never a way to print a barrel unrecorded.

counterfoil.counterfoils.print.run-expiry

12h

How long an unresolved print run stays open.

counterfoil.counterfoils.print.verification-difference-cap

500

Draw numbers named in a verification response before truncation.

counterfoil.drawings.draw-event-page-max

200

Maximum page size on the draw-event sequence.

counterfoil.drawings.integrity.cron

${COUNTERFOIL_DRAWING_INTEGRITY_CRON:0 25 3 * * *}

Nightly drawing integrity sweep.

counterfoil.drawings.integrity.enabled

true

Whether that sweep runs.

counterfoil.drawings.reserve-override-min-reason-length

20

Minimum characters in an R11 reserve override reason.

counterfoil.events.search.max-page-size

200

Interrogation search page cap.

counterfoil.events.verification.cron

${COUNTERFOIL_EVENT_VERIFICATION_CRON:0 0 2 * * *}

Nightly chain verification (02:00 UTC).

counterfoil.events.verification.inline-max-events

200000

Events verified inline before the job switches strategy.

counterfoil.events.verification.on-startup

true

Verify the chain at boot.

counterfoil.orders.max-lines-per-order

20

Pricing-plan lines allowed on one order.

counterfoil.orders.max-tickets-per-order

500

Draw numbers one order may mint.

counterfoil.organization.file

${COUNTERFOIL_ORGANIZATION_FILE:}

The licensee profile. Absent ⇒ no raffle can issue a ticket. Permanent.

counterfoil.password.min-length

8

v1-minimal policy. Complexity and breached-password checks are a pre-certification gate.

counterfoil.payments.max-payment-date-backdating-days

365

How far back a payment’s business date may be set.

counterfoil.pools.integrity.cron

${COUNTERFOIL_POOL_INTEGRITY_CRON:0 15 3 * * *}

Nightly pool determinism sweep.

counterfoil.pools.integrity.enabled

true

Whether that sweep runs.

counterfoil.pools.member-page-max

500

Pool-member page cap.

counterfoil.pools.withdrawal-list-cap

500

Withdrawn members named in a response before truncation.

counterfoil.prizes.integrity.cron

${COUNTERFOIL_PRIZE_INTEGRITY_CRON:0 40 3 * * *}

Nightly prize/fulfilment sweep.

counterfoil.prizes.integrity.enabled

true

Whether that sweep runs.

counterfoil.prizes.max-balance-backdating-days

90

How far back a reserve observation’s asOfDate may be set.

counterfoil.prizes.noncash-documentation-threshold

500.00

Retail value above which a NONCASH prize needs a document reference before a session opens. Organizational diligence policy — no WAC provision names a figure.

counterfoil.prizes.register-page-max

500

Winner-register page cap.

counterfoil.raffle.default-time-zone

America/Los_Angeles

Default zone for a new raffle. Decides the local calendar date the R11 reserve obligation spans.

counterfoil.receipts.auto-issue

true

Issue receipts automatically for fully paid orders.

counterfoil.receipts.auto-issue-interval

30s

How often the auto-issue sweeper runs.

counterfoil.receipts.email.attach-pdf

true

Attach the PDF so both channels deliver an identical document.

counterfoil.receipts.email.backoff

1m

Retry backoff, doubling per attempt.

counterfoil.receipts.email.base-url

${COUNTERFOIL_EMAIL_BASE_URL:\https://api.postmarkapp.com}

Provider endpoint. Must be https or startup fails (R15).

counterfoil.receipts.email.batch-size

50

Deliveries claimed per dispatch pass.

counterfoil.receipts.email.bounce-lookback

7d

How far back each bounce poll looks.

counterfoil.receipts.email.bounce-poll-cron

${COUNTERFOIL_BOUNCE_POLL_CRON:0 7 * * * *}

Bounce poll schedule. Bounces are polled, never received — there is no inbound webhook.

counterfoil.receipts.email.claim-timeout

5m

How long a claimed delivery stays claimed before it is reclaimable.

counterfoil.receipts.email.connect-timeout

5s

Provider connect timeout.

counterfoil.receipts.email.dispatch-interval

30s

How often the outbox dispatcher runs.

counterfoil.receipts.email.enabled

${COUNTERFOIL_EMAIL_ENABLED:false}

Off by default. Disabled, every receipt queues for mail, which is equally lawful.

counterfoil.receipts.email.from-address

${COUNTERFOIL_EMAIL_FROM:}

Sender address; needs a verified sending domain.

counterfoil.receipts.email.from-name

${COUNTERFOIL_EMAIL_FROM_NAME:}

Sender display name.

counterfoil.receipts.email.max-attempts

5

Delivery attempts before the record is failed.

counterfoil.receipts.email.message-stream

outbound

Provider message stream.

counterfoil.receipts.email.provider

${COUNTERFOIL_EMAIL_PROVIDER:disabled}

postmark | disabled.

counterfoil.receipts.email.read-timeout

15s

Provider read timeout.

counterfoil.receipts.email.token

${COUNTERFOIL_EMAIL_API_TOKEN:}

Environment only; never write it into this file.

counterfoil.receipts.max-per-sweep

200

Receipts issued per auto-issue pass. Bounds the drain when auto-issue is first enabled on a live raffle.

counterfoil.receipts.print.batch-expiry

24h

How long an unconfirmed mail batch stays open.

counterfoil.receipts.print.expiry-sweep-interval

15m

How often forgotten mail batches are abandoned.

counterfoil.receipts.print.max-receipts-per-batch

250

Receipts per mail print batch.

counterfoil.receipts.print.page-size

LETTER

LETTER | A4.

counterfoil.receipts.print.window-envelope

true

Position the address block for a #10 double-window envelope.

counterfoil.records.archive.directory

${COUNTERFOIL_ARCHIVE_DIR:/var/lib/counterfoil/archives}

Where completed .cfar files are written.

counterfoil.records.archive.frame-size

1MB

AES-GCM frame size inside the container.

counterfoil.records.archive.max-runtime

2h

An export exceeding this is failed.

counterfoil.records.archive.min-passphrase-length

16

Operational floor on operator input. The Argon2id cost behind it is a code constant, so a deployment cannot weaken key derivation itself.

counterfoil.records.archive.retain-completed-days

3650

How long completed archives stay on disk. Safe at rest by construction — the server does not hold the key.

counterfoil.records.file

${COUNTERFOIL_RECORDS_FILE:}

The WAC 230-11-105 custodian, records location and fiscal-year end. Absent ⇒ no filing, archiving or destroying; sales unaffected. Permanent. state must be WA.

counterfoil.records.integrity.cron

${COUNTERFOIL_RECORDS_INTEGRITY_CRON:0 50 3 * * *}

Nightly records integrity sweep.

counterfoil.records.integrity.enabled

true

Whether that sweep runs.

counterfoil.records.reports.page-size

${COUNTERFOIL_REPORT_PAGE_SIZE:LETTER}

Page size for catalogue reports.

counterfoil.records.reports.record-set-max-pages

500

Page cap on a rendered record set.

counterfoil.session.absolute-expiry

12h

Hard session lifetime regardless of activity.

counterfoil.session.idle-timeout

30m

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

COUNTERFOIL_ARCHIVE_DIR

/var/lib/counterfoil/archives

Directory completed .cfar archives are written to. Needs to survive a restart and be backed up — it is the licensee’s records copy. Binds counterfoil.records.archive.directory.

COUNTERFOIL_BOOTSTRAP_FILE

(empty)

Path to the first-boot JSON ({ "email", "fullName", "initialPassword" }) that seeds one System Administrator when the users table is empty. Delete the file after the first successful boot — unlike the other two file variables, this one is not permanent.

COUNTERFOIL_BOUNCE_POLL_CRON

0 7 * * * *

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.

COUNTERFOIL_DB

counterfoil

MySQL schema name.

COUNTERFOIL_DB_HOST

localhost

MySQL host.

COUNTERFOIL_DB_PASSWORD

(none — required)

MySQL password. The only variable with no default: the server will not start without it.

COUNTERFOIL_DB_USER

counterfoil

MySQL user. Needs DDL rights, because Flyway runs migrations at startup.

COUNTERFOIL_DRAWING_INTEGRITY_CRON

0 25 3 * * *

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.

COUNTERFOIL_EMAIL_API_TOKEN

(empty)

Transactional email provider token. Set it here and nowhere else — never in application.yml, which is a committed file.

COUNTERFOIL_EMAIL_BASE_URL

https://api.postmarkapp.com

Provider endpoint. Must be https; startup fails otherwise, because R15 requires encrypted transport for raffle data leaving the system.

COUNTERFOIL_EMAIL_ENABLED

false

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.

COUNTERFOIL_EMAIL_FROM

(empty)

Sender address. Its domain needs SPF/DKIM/DMARC configured with the provider before you enable email.

COUNTERFOIL_EMAIL_FROM_NAME

(empty)

Sender display name shown to purchasers.

COUNTERFOIL_EMAIL_PROVIDER

disabled

postmark or disabled. Selects which sender implementation is wired in.

COUNTERFOIL_EVENT_VERIFICATION_CRON

0 0 2 * * *

Schedule for re-linking the whole significant-event hash chain (02:00 UTC). A break raises EVENT_CHAIN_BROKEN. Verification also runs at startup unless that is turned off in the YAML.

COUNTERFOIL_FORWARD_HEADERS_STRATEGY

native

Whether the server trusts X-Forwarded-Proto and X-Forwarded-For from a TLS-terminating reverse proxy on loopback or a private address, so a proxied HTTPS request reads as secure and sign-in rate limits apply per browser rather than per proxy. none ignores them. See Section 4.4. Binds server.forward-headers-strategy.

COUNTERFOIL_ORGANIZATION_FILE

(empty)

Path to organization.json, the licensee profile. Absent, the server starts but no raffle can issue its first ticket, so nothing can be sold. Present but invalid, startup fails naming the bad field. Read on every boot and never written.

COUNTERFOIL_POOL_INTEGRITY_CRON

0 15 3 * * *

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.

COUNTERFOIL_PORT

9143

HTTPS listen port.

COUNTERFOIL_PRIZE_INTEGRITY_CRON

0 40 3 * * *

Schedule for the nightly prize sweep — every winner has a fulfilment record, no prize is held twice, and every session opened against complete evidence.

COUNTERFOIL_RECORDS_FILE

(empty)

Path to records.json, the WAC 230-11-105 custodian, records location and fiscal-year end. Absent, filing, archiving and destroying all refuse — and ticket sales are unaffected, which is the correct blast radius and is asserted by test. Because nothing else notices, a smoke test will not catch it: check cfoil records-profile show. state must be WA or startup fails (R12).

COUNTERFOIL_RECORDS_INTEGRITY_CRON

0 50 3 * * *

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.

COUNTERFOIL_LOG_FORMAT

(empty)

Log output format. Empty is human-readable text; ecs emits one JSON object per line with every correlation field as a top-level key, which is what a log collector wants. logstash and gelf are also accepted.

COUNTERFOIL_LOG_FILE

(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.

COUNTERFOIL_LOG_LEVEL

INFO

Level for everything under dev.cfoil. See Section 4.3 for what each level means.

COUNTERFOIL_LOG_LEVEL_DRAW

(inherits)

Level for the drawing-execution area only. Empty inherits COUNTERFOIL_LOG_LEVEL.

COUNTERFOIL_LOG_LEVEL_ORDER

(inherits)

Level for order intake only.

COUNTERFOIL_LOG_LEVEL_PAYMENT

(inherits)

Level for payment recording and reconciliation only.

COUNTERFOIL_LOG_LEVEL_POOL

(inherits)

Level for the drawing-pool eligibility engine only.

COUNTERFOIL_LOG_LEVEL_RECEIPT

(inherits)

Level for receipt issuance and delivery only.

COUNTERFOIL_LOG_LEVEL_RECORDS

(inherits)

Level for WSGC recordkeeping, archives and retention only.

COUNTERFOIL_ACCESS_LOG_LEVEL

INFO

Level for the one-line-per-request access log. OFF disables it without affecting anything else.

COUNTERFOIL_REPORT_PAGE_SIZE

LETTER

Page size for catalogue report PDFs (LETTER or A4).

COUNTERFOIL_UI_ENABLED

true

Serves the administration web interface at /ui/ (see Section 4.4). false and a restart remove it entirely — /ui/ answers 404 — with no effect on the API, cfoil or any session; that is the rollback lever. Binds counterfoil.ui.enabled.

COUNTERFOIL_UI_REQUIRE_HTTPS

true

Refuses every web-interface request the server believes is plaintext (R15). false is for http://localhost development only and logs a warning at startup. Behind a TLS-terminating proxy, leave it true: COUNTERFOIL_FORWARD_HEADERS_STRATEGY makes the proxied request read as HTTPS. Binds counterfoil.ui.require-https.

MYSQL_PORT

3306

MySQL port. The one variable in application.yml without the COUNTERFOIL_ prefix — it is read straight into the datasource URL.

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

COUNTERFOIL_URL

https://localhost:9143

The server’s base origin. Overridden by the active profile’s url, and by a --url flag.

COUNTERFOIL_PROFILE

(the profile marked current in config.json)

Which named profile to use for this invocation. Overridden by --profile.

COUNTERFOIL_TOKEN

(the cached session token for the active profile)

A bearer token to use instead of the one cached in credentials.json. Overridden by --token.

COUNTERFOIL_CONFIG_DIR

~/.counterfoil

Where config.json (named profiles) and credentials.json (cached session tokens, mode 0600) live.

COUNTERFOIL_PRINTER

(none — a usage error if unset)

The print target for cfoil counterfoils print/preview — tcp://host:port, a device file path, cups://queue, or - for stdout. A --printer flag takes precedence over this variable, which in turn takes precedence over the active profile’s own printer.

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

legalName

Yes

The registered legal entity. Printed when there is no dbaName.

dbaName

No

The trading name. When present this is what prints on receipts and counterfoils instead of legalName.

addressLine1

Yes

addressLine2

No

Omit it or leave it out entirely; a blank value is treated the same as absent.

city

Yes

state

Yes

Exactly two letters. Not restricted to WA — the licensee’s mailing address is not the records location, and only records.json carries the Washington requirement.

postalCode

Yes

phone

Yes

Printed on receipts, so it must be a number a purchaser can call.

contactEmail

No

website

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

custodianName

Yes

The named person responsible for the records, not a department.

custodianPhone

Yes

custodianEmail

No

addressLine1

Yes

addressLine2

No

city

Yes

state

Yes

Must be WA. R12 requires raffle records to be maintained in Washington, and the server refuses to start for any other value rather than let a filing claim otherwise.

postalCode

Yes

recordsLocation

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".

fiscalYearEnd

Yes

MM-DD, e.g. 12-31 for a calendar year or 06-30 for a July–June year. Parsed as a real month and day, so 02-30 is refused at startup rather than years later. The three-year retention clock counts from this date.

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

email

Yes

Becomes the account’s login. Lower-cased on save.

fullName

Yes

initialPassword

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

Authentication & sessions

/auth

Issues and revokes the opaque session tokens every other call requires; self-service password change.

User administration

/users

System Administrator management of accounts, roles, lockouts, and retirement.

Significant event log

/events

Read-only interrogation of the append-only, hash-chained audit log, and chain verification.

Raffle configuration

/raffles

The enhanced raffle’s legal parameters — raffle, pricing plans, drawings, prizes, tax rate — with the irreversible pre-sale lock and compliance checklist.

Tax jurisdictions

/tax-jurisdictions

Local gambling-tax rate lookup feeding the raffle-creation suggestion. Read by any role; maintained by the System Administrator.

Order intake

/raffles/{id}/orders, /raffles/{id}/purchasers

The sales-entry API: quote, place a complete order (minting its draw numbers), read, and correct. Raffle-scoped purchaser search and correction.

Sales units

/sales-units

System Administrator registry of the stations and devices permitted to submit orders.

Payments

/raffles/{id}/orders/{orderId}/payments, /raffles/{id}/payments

The append-only payment ledger — record, reverse, read. Record-only: Counterfoil never processes a payment and holds no cardholder data.

Refunds

/raffles/{id}/orders/{orderId}/refund-requests, /raffles/{id}/refund-requests

The two-person refund control; approval returns the money and voids every ticket on the order in one transaction.

Reconciliation

/raffles/{id}/reconciliation, /raffles/{id}/financial-summary

Receipts by method/channel and the R18 gross-receipts breakout by pricing-plan option; gross, prize exposure and provisional net.

Receipts

/raffles/{id}/receipts, /raffles/{id}/orders/{orderId}/receipt

Immutable receipt snapshots with every R9/R10 disclosure, their PDF, and their delivery by email or mail.

Print batches

/raffles/{id}/receipt-print-batches

Mail print runs: generate, download the batch PDF, confirm printed, or abandon a jam.

Organization

/organization

Read-only view of the licensee this server sells for. Configured by file, not by API.

Counterfoils

/raffles/{id}/drawings/{drawingId}/counterfoil-batches, /raffles/{id}/counterfoil-batches

The physical drawing pool: cut and freeze a barrel, read its manifest, and check it against the eligible pool.

Print runs

/raffles/{id}/counterfoil-batches/{batchId}/print-runs

Open a run over a range of stubs, stream it to a printer, and confirm or abandon it. Streaming marks nothing.

Drawing pools

/raffles/{id}/pools, /raffles/{id}/drawings/{drawingId}/pool

Which tickets are in which drawing, computed from the locked eligibility standard — and the one irreversible closure that freezes it.

Drawing execution

/raffles/{id}/drawings/{drawingId}/readiness, …/sessions, /raffles/{id}/sessions/{id}

Conducting the physical drawing: the readiness gate, the ordered draw sequence, the two-person winner control, and the Raffle Drawing Report.

Prizes & fulfilment

/raffles/{id}/prize-records, /raffles/{id}/reserve-balances, /raffles/{id}/winners

WAC 230-11-065 prize evidence, the append-only reserve-balance log, the R11 check, and post-drawing fulfilment.

Record set

/raffles/{id}/record-set

The WSGC standard raffle record set, and the ten internal identities a filing must satisfy.

Tax worksheet

/raffles/{id}/tax-worksheet

The R19 local gambling-tax filing: previewed on demand, finalized once, frozen forever.

Report catalogue

/raffles/{id}/reports

Every report in the product, in JSON, CSV and PDF, with one cover page and one export event.

Archives

/raffles/{id}/archives

Encrypted per-raffle record exports for in-state records custody. Verified and extracted offline.

Retention & destruction

/raffles/{id}/retention, /raffles/{id}/destructions

R12 retention clocks and the two-person destruction control. Destruction is redaction under a permanent certificate.

Records profile

/records-profile

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-8 unless noted (e.g. future PDF/CSV report exports return application/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 AUDITOR role is read-only across the entire API; SYSTEM_ADMINISTRATOR manages accounts and system ops but holds no gaming-record write authority (segregation of duties).

  • Every 403 on a mutating endpoint emits a PERMISSION_DENIED significant event.

B.2.6. Request correlation

  • Every response carries an X-Request-Id header — 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-Id on 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 --verbose prints 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 suffix Identity (requestedByIdentity, approvedByIdentity, actorIdentity), in the form Full 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 DISABLED user’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. actorUserId is still returned on those events.

  • GET /api/v1/users stays SYSTEM_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> (default page=0, size=50, max size=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

400

Malformed / invalid request

VALIDATION_ERROR, WEAK_PASSWORD, NO_ROLES, UNKNOWN_ROLE, RULE_VIOLATION (regulatory rule; error.rule names it — e.g. P1 for the Washington-residents-only policy), FEATURE_NOT_AVAILABLE (reserved value), ATTESTATION_REQUIRED, CARD_DATA_REJECTED

401

Not authenticated (missing/expired/invalid token or bad credentials)

UNAUTHENTICATED, INVALID_CREDENTIALS

403

Authenticated but not permitted

FORBIDDEN

404

Resource not found

NOT_FOUND

409

Conflict with current state

EMAIL_TAKEN, NOT_LOCKED, CONFIGURATION_LOCKED, TAX_RATE_FINALIZED, LICENSE_REQUIRED, INVENTORY_EXHAUSTED, TICKET_NOT_VOIDABLE, VOID_REQUEST_NOT_PENDING, VOID_APPROVAL_SELF, SALES_WINDOW_CLOSED, SALES_UNIT_UNKNOWN, PURCHASER_MATCH_FOUND (a prompt, not a block — see Section B.10), PAYMENT_EXCEEDS_ORDER_TOTAL, ORDER_NOT_PAYABLE, PAYMENT_ALREADY_REVERSED, REFUND_NOT_ELIGIBLE

423

Resource locked

ACCOUNT_LOCKED

429

Rate limited

RATE_LIMITED

503

Backing store unavailable (fail-closed; no degraded writes)

SERVICE_UNAVAILABLE

B.3. Roles

Fixed enum (no runtime editing in v1). Used verbatim as the authorization role string.

Role Summary

RAFFLE_ADMINISTRATOR

Full raffle configuration, drawing execution, reporting.

SALES_OPERATOR

Order entry and receipt issuance; no config or unilateral void authority.

FINANCE_RECORDS_OFFICER

Payment recording, reconciliation, reports, exports.

AUDITOR

Read-only across all records, logs, and reports. Cannot mutate anything.

SYSTEM_ADMINISTRATOR

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

401

INVALID_CREDENTIALS

Unknown email or wrong password (identical response either way — no account enumeration).

423

ACCOUNT_LOCKED

Account locked after 5 consecutive failures; needs a System Administrator unlock.

429

RATE_LIMITED

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

POST /api/v1/users

Create a user

201 Created + user

409 EMAIL_TAKEN, 400 NO_ROLES / UNKNOWN_ROLE / WEAK_PASSWORD

GET /api/v1/users

List users (paged; ?status=, ?role= filters)

200 OK (paged envelope)

—

GET /api/v1/users/{id}

Fetch one user

200 OK + user

404 NOT_FOUND

PATCH /api/v1/users/{id}

Update email / fullName / roles / status

200 OK + user

400, 404 NOT_FOUND, 409 EMAIL_TAKEN

POST /api/v1/users/{id}/unlock

Clear a lockout, reset failure count

200 OK + user

404 NOT_FOUND, 409 NOT_LOCKED

POST /api/v1/users/{id}/reset-password

Set a new password; forces change on next login

204 No Content

404 NOT_FOUND, 400 WEAK_PASSWORD

POST /api/v1/users/{id}/disable

Retire a user; revokes all their sessions

204 No Content

404 NOT_FOUND

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).

GET /api/v1/events

Search the event log (R14). All filters are optional and combinable; results are paged (newest first).

Query parameters:

Param Meaning

from, to

ISO-8601 UTC bounds on the event’s UTC timestamp; half-open [from, to).

eventType

Repeatable; matches any of the listed types (e.g. ?eventType=LOGIN_FAILURE&eventType=ACCOUNT_LOCKOUT).

actorUserId

Numeric user id, or the literal system to match events with no acting user.

entityType, entityId

Exact match on the affected entity (e.g. entityType=users&entityId=57).

page, size

Standard pagination (size default 50, max 200).

sort

seq (default) or occurredAt; both descending.

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

POST /api/v1/raffles

Create a raffle (DRAFT)

201 Created + raffle

400 VALIDATION_ERROR, 400 RULE_VIOLATION (R19)

GET /api/v1/raffles

List (paged; ?configStatus= filter)

200 OK (paged envelope)

—

GET /api/v1/raffles/{id}

Full config: raffle + tax + pricingPlans[] + drawings[] (with prizes[]); ?includeRemoved=true shows soft-removed children

200 OK

404 NOT_FOUND

PATCH /api/v1/raffles/{id}

Edit core fields (not tax rate). DRAFT only.

200 OK + raffle

400, 404, 409 CONFIGURATION_LOCKED

PUT /api/v1/raffles/{id}/tax-rate

Set jurisdiction + effective rate. Allowed in DRAFT and LOCKED, until worksheet finalization

200 OK + raffle

400 RULE_VIOLATION (R19), 404, 409 TAX_RATE_FINALIZED

GET /api/v1/raffles/{id}/compliance-checklist

Pre-lock pass/fail line items: R3, R4, R5, R10, R18, LICENSE

200 OK

404 NOT_FOUND

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

POST /api/v1/raffles/{id}/pricing-plans

Add { label, ticketCount, price }; server records perTicketRate (price ÷ count, scale 4 — R18)

201 Created + plan

400 RULE_VIOLATION (R3), 400 VALIDATION_ERROR (duplicate active ticketCount), 409 CONFIGURATION_LOCKED

PATCH /api/v1/raffles/{id}/pricing-plans/{planId}

Edit label / count / price; rate re-derived and re-recorded

200 OK + plan

as above, 404 NOT_FOUND

DELETE /api/v1/raffles/{id}/pricing-plans/{planId}

Soft removal (status = REMOVED; never hard-deleted)

204 No Content

404, 409 CONFIGURATION_LOCKED

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

POST /api/v1/raffles/{id}/drawings

Add { type, name, scheduledAt, location, winnerMustBePresent, entryCutoffAt?, minTicketCount? }

201 Created + drawing

400 FEATURE_NOT_AVAILABLE (REFER_A_FRIEND), 400 VALIDATION_ERROR (params vs type, cutoff outside sales window, second GRAND), 409 CONFIGURATION_LOCKED

PATCH /api/v1/raffles/{id}/drawings/{drawingId}

Edit; same per-type validation

200 OK + drawing

as above, 404 NOT_FOUND

DELETE /api/v1/raffles/{id}/drawings/{drawingId}

Soft removal; cascades REMOVED to its prizes

204 No Content

404, 409 CONFIGURATION_LOCKED

POST /api/v1/raffles/{id}/drawings/{drawingId}/prizes

Add { description, valueType, fixedValue } — v1 accepts valueType: "FIXED" only

201 Created + prize

400 RULE_VIOLATION (R4), 400 FEATURE_NOT_AVAILABLE (PERCENTAGE_OF_RECEIPTS), 400 VALIDATION_ERROR, 409 CONFIGURATION_LOCKED

PATCH /api/v1/raffles/{id}/drawings/{drawingId}/prizes/{prizeId}

Edit prize

200 OK + prize

as above, 404 NOT_FOUND

DELETE /api/v1/raffles/{id}/drawings/{drawingId}/prizes/{prizeId}

Soft removal

204 No Content

404, 409 CONFIGURATION_LOCKED

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

GET /api/v1/tax-jurisdictions

all authenticated roles

List jurisdiction entries (paged)

200 OK (paged envelope)

GET /api/v1/tax-jurisdictions/suggestion?city=&county=&state=

all authenticated roles

Most-specific-match rate suggestion (city → county → state, effective today)

200 OK

POST /api/v1/tax-jurisdictions

SYSTEM_ADMINISTRATOR

Append an effective-dated rate row

201 Created

PATCH /api/v1/tax-jurisdictions/{id}

SYSTEM_ADMINISTRATOR

Correct a row not yet in effect

200 OK · 409 JURISDICTION_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

GET /api/v1/raffles/{id}/ticket-inventory

Reconciling report: per-series and totals, unissued + issued + voided = total

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/tickets

Paged ticket list; filters ?status=ISSUED|VOID, ?seriesCode=, ?voidPending=true. Each row carries seriesCode, issuedAt and voidedAt

200 OK (paged envelope)

404 NOT_FOUND

GET /api/v1/raffles/{id}/tickets/{drawNumber}

Look up one ticket by its printed draw number, with its seriesCode

200 OK

404 NOT_FOUND

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

POST /api/v1/raffles/{id}/tickets/{ticketId}/void-requests

SALES_OPERATOR, RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Initiate { reason, reasonText }

201 Created + request (PENDING)

404, 409 TICKET_NOT_VOIDABLE, 400 VALIDATION_ERROR

POST /api/v1/raffles/{id}/void-requests/{requestId}/approve

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Approve → ticket VOID. Body { note }, required

200 OK (APPROVED)

400 VALIDATION_ERROR, 404, 409 VOID_REQUEST_NOT_PENDING, 409 VOID_APPROVAL_SELF

POST /api/v1/raffles/{id}/void-requests/{requestId}/reject

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Reject → request REJECTED, ticket unchanged. Body { note }, required

200 OK (REJECTED)

400 VALIDATION_ERROR, 404, 409 VOID_REQUEST_NOT_PENDING

GET /api/v1/raffles/{id}/void-requests

all authenticated roles

Work queue; filter ?status=PENDING

200 OK (paged envelope)

404 NOT_FOUND

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

POST /api/v1/raffles/{id}/orders/preview

SALES_OPERATOR, RAFFLE_ADMINISTRATOR

Advisory quote: totals, per-line R18 rates, sales-window state, duplicate candidates. Writes nothing

200 OK + preview

400 VALIDATION_ERROR, 400 RULE_VIOLATION (P1), 404 NOT_FOUND

POST /api/v1/raffles/{id}/orders

SALES_OPERATOR, RAFFLE_ADMINISTRATOR

Place a complete order and mint its draw numbers

201 Created + order

400 VALIDATION_ERROR / ATTESTATION_REQUIRED / RULE_VIOLATION (P1), 409 SALES_WINDOW_CLOSED / PURCHASER_MATCH_FOUND / SALES_UNIT_UNKNOWN / LICENSE_REQUIRED / INVENTORY_EXHAUSTED, 404 NOT_FOUND

POST /api/v1/raffles/{id}/orders (replay)

as above

Same clientRequestId + salesUnitCode as a prior call

200 OK + the original order

—

GET /api/v1/raffles/{id}/orders

all authenticated roles

List, paged. ?channel=, ?salesUnitCode=, ?purchaserId=, ?status=, ?from=, ?to= (ISO-8601 UTC, half-open)

200 OK (paged envelope)

404 NOT_FOUND

GET /api/v1/raffles/{id}/orders/{orderId}

all authenticated roles

One order with its lines and issued draw numbers

200 OK + order

404 NOT_FOUND

PATCH /api/v1/raffles/{id}/orders/{orderId}

SALES_OPERATOR, RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Correct sourceReference / note only

200 OK + order

400 VALIDATION_ERROR, 404 NOT_FOUND

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

GET /api/v1/raffles/{id}/purchasers

all authenticated roles

Search, paged. ?name= (prefix, normalized), ?postalCode=, ?phone=

200 OK (paged envelope)

GET /api/v1/raffles/{id}/purchasers/{purchaserId}

all authenticated roles

One purchaser

200 OK

PATCH /api/v1/raffles/{id}/purchasers/{purchaserId}

SALES_OPERATOR, RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Correct name / address / phone / email

200 OK

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

POST /api/v1/sales-units

SYSTEM_ADMINISTRATOR

Register { code, name, type }

201 Created + unit

GET /api/v1/sales-units

SYSTEM_ADMINISTRATOR

List (paged; ?status=ACTIVE|RETIRED)

200 OK (paged envelope)

POST /api/v1/sales-units/{id}/retire

SYSTEM_ADMINISTRATOR

Retire a station; its orders stay attributed

204 No Content

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

PAYMENT

positive

no

Money in.

REVERSAL

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.

REFUND

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

POST /api/v1/raffles/{id}/orders/{orderId}/payments

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Record a payment

201 Created + entry & order state

400 VALIDATION_ERROR / CARD_DATA_REJECTED, 409 PAYMENT_EXCEEDS_ORDER_TOTAL / ORDER_NOT_PAYABLE / VALIDATION_ERROR (duplicate processorReference), 404 NOT_FOUND

POST …/payments (replay)

—

Same clientRequestId on the same order

200 OK + the original entry

—

POST /api/v1/raffles/{id}/orders/{orderId}/payments/{paymentId}/reversal

FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Correct an entry in full. { clientRequestId, reason, paymentDate?, note }: the coded reason and the free-text note are both required

201 Created + reversal

400 VALIDATION_ERROR / CARD_DATA_REJECTED, 409 PAYMENT_ALREADY_REVERSED, 404 NOT_FOUND

GET /api/v1/raffles/{id}/orders/{orderId}/payments

all authenticated roles

The order’s ledger + derived state

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/payments

all authenticated roles

Search, paged. ?entryType=&method=&orderId=&basis=&from=&to=

200 OK (paged envelope)

400 VALIDATION_ERROR, 404 NOT_FOUND

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

POST /api/v1/raffles/{id}/orders/{orderId}/refund-requests

FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Open a request (whole order)

201 Created

400 VALIDATION_ERROR / CARD_DATA_REJECTED, 409 REFUND_NOT_ELIGIBLE, 404 NOT_FOUND

POST /api/v1/raffles/{id}/refund-requests/{reqId}/approval

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER (≠ requester)

Approve: money back, all tickets void, order closed. Body { note, paymentDate? }, note required

200 OK

400 VALIDATION_ERROR, 409 VOID_APPROVAL_SELF / VOID_REQUEST_NOT_PENDING / REFUND_NOT_ELIGIBLE, 404 NOT_FOUND

POST /api/v1/raffles/{id}/refund-requests/{reqId}/rejection

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Reject; nothing moves. Body { note }, required

200 OK

400 VALIDATION_ERROR, 409 VOID_REQUEST_NOT_PENDING, 404 NOT_FOUND

GET /api/v1/raffles/{id}/refund-requests

all authenticated roles

List, paged (?status=)

200 OK (paged envelope)

404 NOT_FOUND

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

GET /api/v1/raffles/{id}/reconciliation?from=&to=&basis=&format=

all authenticated roles

The reconciliation report; ?format=csv for CSV

200 OK

GET /api/v1/raffles/{id}/financial-summary

all authenticated roles

Gross receipts to date, prize exposure, provisional net

200 OK

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

POST /api/v1/raffles/{id}/orders/{orderId}/receipt?deliver=

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Issue the receipt (idempotent)

201 Created / 200 OK if already issued

409 RECEIPT_NOT_ELIGIBLE / ORGANIZATION_NOT_CONFIGURED, 400 RULE_VIOLATION (a required disclosure is missing), 404 NOT_FOUND

GET /api/v1/raffles/{id}/orders/{orderId}/receipt

all authenticated roles

The receipt in force for an order

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/receipts?status=&deliveryState=&from=&to=&page=&size=

all authenticated roles

Search / the delivery work queue (paged)

200 OK

400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/receipts/{receiptId}

all authenticated roles

One receipt, any version

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/receipts/{receiptId}/document?format=pdf|json

all authenticated roles

Render the snapshot (application/pdf by default)

200 OK

400 VALIDATION_ERROR, 404 NOT_FOUND

POST /api/v1/raffles/{id}/receipts/{receiptId}/deliveries

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Queue a delivery

202 Accepted

409 RECEIPT_NO_EMAIL_ON_FILE / EMAIL_DELIVERY_DISABLED / RECEIPT_NOT_ELIGIBLE, 404 NOT_FOUND

POST /api/v1/raffles/{id}/receipts/{receiptId}/reissue

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Supersede with a fresh snapshot

201 Created

409 RECEIPT_NOT_ELIGIBLE, 404 NOT_FOUND

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.

Method & path Roles Purpose Success Errors

POST /api/v1/raffles/{id}/receipt-print-batches

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

Open a run over everything waiting for the post, or return the open one

201 Created / 200 OK

400 VALIDATION_ERROR (nothing waiting), 404 NOT_FOUND

GET /api/v1/raffles/{id}/receipt-print-batches?status=&page=&size=

all authenticated roles

List runs (paged)

200 OK

400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/receipt-print-batches/{batchId}

all authenticated roles

One run and its receipts

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/receipt-print-batches/{batchId}/document

all authenticated roles

The batch PDF (application/pdf)

200 OK

404 NOT_FOUND

POST /api/v1/raffles/{id}/receipt-print-batches/{batchId}/confirmation

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

It printed — mark every member mailed

200 OK

409 PRINT_BATCH_NOT_OPEN, 404 NOT_FOUND

POST /api/v1/raffles/{id}/receipt-print-batches/{batchId}/abandonment

SALES_OPERATOR, FINANCE_RECORDS_OFFICER, RAFFLE_ADMINISTRATOR

It jammed — requeue every member

200 OK

409 PRINT_BATCH_NOT_OPEN, 400 VALIDATION_ERROR, 404 NOT_FOUND

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

POST /api/v1/raffles/{id}/drawings/{drawingId}/counterfoil-batches

RAFFLE_ADMINISTRATOR

Cut a barrel (mode = INITIAL | SUPPLEMENT | REPLACEMENT). reason is required in every mode (≤ 500 characters)

201 Created

409 COUNTERFOIL_POOL_EMPTY, 400 FEATURE_NOT_AVAILABLE (pool type), 400 RULE_VIOLATION (raffle not selling yet), 400 VALIDATION_ERROR, 404 NOT_FOUND

GET /api/v1/raffles/{id}/drawings/{drawingId}/counterfoil-batches?status=&page=&size=

all authenticated roles

Batches for one drawing (paged)

200 OK

400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/counterfoil-batches?status=&page=&size=

all authenticated roles

Every batch in the raffle (paged)

200 OK

400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}

all authenticated roles

One batch and its print coverage

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}/counterfoils?page=&size=

all authenticated roles

The frozen stubs (paged)

200 OK

400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}/manifest?format=json|csv|pdf

all authenticated roles

The Counterfoil Manifest (R17)

200 OK

400 VALIDATION_ERROR, 404 NOT_FOUND

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}/preview?profile=&count=

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Alignment proof — the first few stubs, capped server-side

200 OK

400 VALIDATION_ERROR, 404 NOT_FOUND

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.

Method & path Roles Purpose Success Errors

POST /api/v1/raffles/{id}/counterfoil-batches/{batchId}/print-runs

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

Open a run over {fromSequence, toSequence, renderProfile, deviceLabel, copies}

201 Created

400 PRINT_RANGE_INVALID, 409 COUNTERFOIL_BATCH_SUPERSEDED, 400 RULE_VIOLATION (duplicate copies on a thermal profile), 404 NOT_FOUND

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}/print-runs

all authenticated roles

Run history

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}/print-runs/{runId}

all authenticated roles

One run

200 OK

404 NOT_FOUND

GET /api/v1/raffles/{id}/counterfoil-batches/{batchId}/print-runs/{runId}/stream

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

The run’s device bytes

200 OK

409 PRINT_RUN_NOT_OPEN / COUNTERFOIL_BATCH_SUPERSEDED, 404 NOT_FOUND

POST /api/v1/raffles/{id}/counterfoil-batches/{batchId}/print-runs/{runId}/confirmation

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

It came out clean

200 OK

409 PRINT_RUN_NOT_OPEN, 404 NOT_FOUND

POST /api/v1/raffles/{id}/counterfoil-batches/{batchId}/print-runs/{runId}/abandonment

RAFFLE_ADMINISTRATOR, FINANCE_RECORDS_OFFICER

It jammed — reason required

200 OK

409 PRINT_RUN_NOT_OPEN, 400 VALIDATION_ERROR, 404 NOT_FOUND

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

GET /api/v1/raffles/{id}/drawings/{drawingId}/counterfoil-verification

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

GRAND_V1

GRAND

Every paid, non-void ticket ordered on or before the raffle’s sales close

EARLY_BIRD_V1

EARLY_BIRD

The same, bounded by the drawing’s own cutoff. Winning removes nothing — no rule takes a winner as input (R6)

MULTIPLE_TICKET_ALL_TICKETS_V1

MULTIPLE_TICKET

Every ticket of every purchaser holding at least minTicketCount by the cutoff. Bundle tickets count individually (R18); tickets ordered after the cutoff neither count nor enter

—

REFER_A_FRIEND

No rule registered → 400 FEATURE_NOT_AVAILABLE (L4)

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

GET /api/v1/raffles/{id}/pools

all authenticated roles

Every drawing’s pool state — the drawing-prep view

200 OK

GET /api/v1/raffles/{id}/drawings/{drawingId}/pool

all authenticated roles

One pool: rule narrative, counts, and (closed) the withdrawn list

200 OK

GET /api/v1/raffles/{id}/drawings/{drawingId}/pool/members?format=json|csv&page=&size=

all authenticated roles

Members, each with its standing

200 OK

POST /api/v1/raffles/{id}/drawings/{drawingId}/pool/closure

RAFFLE_ADMINISTRATOR

Close it — irreversible. { acknowledgePendingPayments, note }: note is required

201 Created

GET /api/v1/raffles/{id}/drawings/{drawingId}/pool/verification

all authenticated roles

Recompute at the closing instant and compare

200 OK

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

GET /api/v1/raffles/{id}/drawings/{drawingId}/readiness

all authenticated roles

The gate, previewed: blockers, pool, manifests, reserve, stubs needing attestation

200 OK

404 NOT_FOUND

POST /api/v1/raffles/{id}/drawings/{drawingId}/sessions

RAFFLE_ADMINISTRATOR

Open a session; freezes everything

201 Created

409 POOL_NOT_CLOSED, 409 POOL_EMPTY, 409 DRAWING_NOT_READY, 409 WITHDRAWN_STUB_UNACKNOWLEDGED, 409 PRIZE_RECORD_REQUIRED, 409 PRIZE_DOCUMENTATION_REQUIRED, 409 ALL_PRIZES_AWARDED, 409 RESERVE_OVERRIDE_REQUIRED, 409 SESSION_ALREADY_OPEN, 400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/drawings/{drawingId}/sessions?status=&page=&size=

all authenticated roles

Sessions for a drawing

200 OK

400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/sessions/{sessionId}

all authenticated roles

One session: frozen snapshot, live counts, outstanding prizes

200 OK

404 NOT_FOUND

POST /api/v1/raffles/{id}/sessions/{sessionId}/closure

RAFFLE_ADMINISTRATOR

Close and generate the report. { closingNote }, required; printed on the report

200 OK

409 SESSION_NOT_OPEN, 400 VALIDATION_ERROR

POST /api/v1/raffles/{id}/sessions/{sessionId}/abandonment

RAFFLE_ADMINISTRATOR

Abandon — reason required

200 OK

409 SESSION_NOT_OPEN, 409 SESSION_HAS_WINNERS, 400 VALIDATION_ERROR

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

POST /api/v1/raffles/{id}/sessions/{sessionId}/draw-events

RAFFLE_ADMINISTRATOR

Present a stub: { entryMethod, rawInput }

201 Created — accepted or rejected, both in the body

409 SESSION_NOT_OPEN, 400 VALIDATION_ERROR

POST /api/v1/raffles/{id}/sessions/{sessionId}/draw-events/{eventId}/award

RAFFLE_ADMINISTRATOR

Confirm the winner: { drawingPrizeId, notes, witness }

200 OK

409 DRAW_EVENT_NOT_PENDING, 403 WITNESS_INVALID, 409 PRIZE_ALREADY_AWARDED, 423 ACCOUNT_LOCKED, 429 RATE_LIMITED

POST /api/v1/raffles/{id}/sessions/{sessionId}/draw-events/{eventId}/disqualification

RAFFLE_ADMINISTRATOR

Set aside: { reason, reasonText }

200 OK

409 DRAW_EVENT_NOT_PENDING, 400 VALIDATION_ERROR

GET /api/v1/raffles/{id}/sessions/{sessionId}/draw-events?page=&size=

all authenticated roles

The ordered sequence

200 OK

404 NOT_FOUND

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

GET /api/v1/raffles/{id}/sessions/{sessionId}/report?format=json|csv|pdf

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

POST /api/v1/raffles/{id}/prize-records

FINANCE_RECORDS_OFFICER

File evidence: { drawingPrizeId, prizeType, retailValue, supportingDocumentType?, supportingDocumentRef?, documentDatedOn?, supplier?, notes? }

201 Created

PATCH /api/v1/raffles/{id}/prize-records/{recordId}

FINANCE_RECORDS_OFFICER

Correct type / value / document fields / supplier / notes

200 OK

GET /api/v1/raffles/{id}/prize-records?drawingId=

all authenticated roles

The evidence on file

200 OK

GET /api/v1/raffles/{id}/prize-records/missing?drawingId=

all authenticated roles

Configured prizes with no record — the worklist

200 OK

GET /api/v1/raffles/{id}/prize-records/{recordId}

all authenticated roles

One record

200 OK

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

POST /api/v1/raffles/{id}/reserve-balances

FINANCE_RECORDS_OFFICER

Record one observation: { balanceAmount, asOfDate, sourceDocument, note? }

201 Created

GET /api/v1/raffles/{id}/reserve-balances?page=&size=

all authenticated roles

The observation log, newest first

200 OK

GET /api/v1/raffles/{id}/drawings/{drawingId}/reserve-check

all authenticated roles

The R11 position now, with the full breakdown

200 OK

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

GET /api/v1/raffles/{id}/winners?status=&drawingId=&page=&size=

all authenticated roles

Winners with fulfilment status

200 OK

GET /api/v1/raffles/{id}/winners/{fulfilmentId}

all authenticated roles

One winner and their fulfilment

200 OK

PATCH /api/v1/raffles/{id}/winners/{fulfilmentId}

FINANCE_RECORDS_OFFICER

Record prizeChoice / notes while PENDING

200 OK

POST /api/v1/raffles/{id}/winners/{fulfilmentId}/fulfilment

FINANCE_RECORDS_OFFICER

Delivered: { deliveredOn, acknowledgmentReference, prizeChoice?, notes? } → AWARDED

200 OK

POST /api/v1/raffles/{id}/winners/{fulfilmentId}/forfeiture

FINANCE_RECORDS_OFFICER

Not claimed: { reason } → FORFEITED_REDRAWN

200 OK

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

GET /api/v1/raffles/{id}/winner-register?format=json|csv|pdf

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

GET /api/v1/organization

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

GET /api/v1/raffles/{id}/record-set?format=json|csv|pdf

all authenticated roles

The record set

200 OK

GET /api/v1/raffles/{id}/record-set/reconciliation

all authenticated roles

The ten identities and their verdicts

200 OK

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

GET /api/v1/raffles/{id}/tax-worksheet?format=json|csv|pdf

all authenticated roles

The finalized worksheet, or a live preview marked finalized:false

200 OK

POST /api/v1/raffles/{id}/tax-worksheet

RAFFLE_ADMINISTRATOR

{ acknowledgeSharedExemption?, note } → finalize, freezing the rate. note is required, stored, and returned as finalizationNote

201 Created

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

GET /api/v1/raffles/{id}/reports

all authenticated roles

The catalogue: keys, titles, owning module, formats, parameters

200 OK

GET /api/v1/raffles/{id}/reports/{reportKey}?format=&from=&to=&basis=

all authenticated roles

Any report, uniformly, with a WAC 230-11-105 cover page

200 OK

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

POST /api/v1/raffles/{id}/archives

FINANCE_RECORDS_OFFICER

{ passphrase } → produce the archive

202 Accepted

GET /api/v1/raffles/{id}/archives

all authenticated roles

Export history with hashes, sizes and record counts

200 OK

GET /api/v1/raffles/{id}/archives/{exportId}

all authenticated roles

One export, including recordCounts

200 OK

GET /api/v1/raffles/{id}/archives/{exportId}/content

FINANCE_RECORDS_OFFICER

The .cfar bytes

200 OK

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

GET /api/v1/raffles/{id}/retention

all authenticated roles

Per record class: retainUntil, state, certificate

200 OK

POST /api/v1/raffles/{id}/destructions

FINANCE_RECORDS_OFFICER

{ recordClass, reason } → PENDING with a preview

201 Created

GET /api/v1/raffles/{id}/destructions

all authenticated roles

Requests and permanent certificates

200 OK

POST /api/v1/raffles/{id}/destructions/{requestId}/approval

RAFFLE_ADMINISTRATOR, SYSTEM_ADMINISTRATOR

{ approve, note }, both required → execute (true) or reject (false)

200 OK

GET /api/v1/raffles/{id}/destructions/certificates/{certificateId}

all authenticated roles

One permanent certificate

200 OK

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

GET /api/v1/records-profile

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 (/raffles/{id}/record-set + /reconciliation), the R19 tax worksheet (GET/POST /raffles/{id}/tax-worksheet), the uniform report catalogue (/raffles/{id}/reports — brings reconciliation, financial summary and ticket inventory into PDF), encrypted archives (/raffles/{id}/archives + /content), retention and the two-person destruction control (/raffles/{id}/retention, /destructions + /approval, /certificates/{id}), and the read-only /records-profile. POST/PATCH /tax-jurisdictions are new and SYSTEM_ADMINISTRATOR-only; a rate change is an append and an in-effect row cannot be edited. New error codes RECORD_SET_NOT_RECONCILED, RAFFLE_NOT_COMPLETE, TAX_WORKSHEET_FINALIZED, TAX_WORKSHEET_NOT_FINALIZED, EXEMPTION_ALREADY_CLAIMED, CUSTODIAN_NOT_CONFIGURED, FISCAL_YEAR_NOT_CONFIGURED, JURISDICTION_IN_EFFECT, ARCHIVE_KEY_WEAK, ARCHIVE_NOT_READY, ARCHIVE_REQUIRED, RETENTION_NOT_EXPIRED, DESTRUCTION_NOT_PENDING, DESTRUCTION_APPROVAL_SELF; one new event type, RECORDS_DESTROYED — the first since Module 7, so a rolled-back client renders it as a raw string. Breaking for deployments: COUNTERFOIL_RECORDS_FILE must be set before any filing, archive or destruction; ticket sales are unaffected by its absence. GET /raffles/{id}/winner-register gains custodian fields in its identity block, which are excluded from its content hash.

2026-07-29

Prize records, reserve balances, winners & fulfilment, drawing sessions

Added prize evidence (/raffles/{id}/prize-records + /missing), the append-only reserve-balance log (/raffles/{id}/reserve-balances), the on-demand R11 check (/raffles/{id}/drawings/{drawingId}/reserve-check), winner fulfilment (/raffles/{id}/winners
/fulfilment, /forfeiture) and the prize-and-winner register (/raffles/{id}/winner-register). New error codes PRIZE_RECORD_REQUIRED, PRIZE_DOCUMENTATION_REQUIRED, PRIZE_RECORD_EXISTS, PRIZE_RECORD_LOCKED, PRIZE_VALUE_MISMATCH, ALL_PRIZES_AWARDED, FULFILMENT_NOT_PENDING, PRIZE_ALREADY_FULFILLED; no new event types. Behaviour change on POST /raffles/{id}/drawings/{drawingId}/sessions: the reserve check is now real, so reserveStatus can be PASSED and reserveCashPrizeTotal now excludes noncash prizes and spans every drawing sharing the local calendar date; three non-overridable prize-side blockers join the gate. Sessions already closed keep the snapshot they froze.

2026-07-27

Counterfoils, print runs, verification

Added counterfoil batches (/raffles/{id}/drawings/{drawingId}/counterfoil-batches, /raffles/{id}/counterfoil-batches + /counterfoils, /manifest, /preview), print runs (/print-runs + /stream, /confirmation, /abandonment) and the manifest-vs-pool check (/raffles/{id}/drawings/{drawingId}/counterfoil-verification). New error codes COUNTERFOIL_POOL_EMPTY, COUNTERFOIL_BATCH_SUPERSEDED, PRINT_RUN_NOT_OPEN, PRINT_RANGE_INVALID; no new event types. MULTIPLE_TICKET drawing pools return FEATURE_NOT_AVAILABLE until Module 9 ships the eligibility engine.

2026-07-26

Receipts, print batches, organization, raffle configuration

Added receipts (/raffles/{id}/orders/{orderId}/receipt, /raffles/{id}/receipts + /document, /deliveries, /reissue), mail print runs (/raffles/{id}/receipt-print-batches + /document, /confirmation, /abandonment) and the read-only /organization. New error codes RECEIPT_NOT_ELIGIBLE, RECEIPT_NO_EMAIL_ON_FILE, EMAIL_DELIVERY_DISABLED, ORGANIZATION_NOT_CONFIGURED, PRINT_BATCH_NOT_OPEN; new event type RECEIPT_DELIVERY_FAILED. Breaking for deployments: a raffle can no longer issue its first ticket unless an organization profile is configured, and the compliance checklist gains an ORGANIZATION line item.

2026-07-26

Payments, refunds, reconciliation, order intake

Added the record-only payment ledger (/raffles/{id}/orders/{orderId}/payments + /reversal, GET /raffles/{id}/payments), the two-person refund control (/refund-requests, /approval, /rejection), and reporting (/reconciliation with JSON + CSV, /financial-summary). Added ?paymentStatus= to the order list as the unpaid work queue. New error codes CARD_DATA_REJECTED, PAYMENT_EXCEEDS_ORDER_TOTAL, ORDER_NOT_PAYABLE, PAYMENT_ALREADY_REVERSED, REFUND_NOT_ELIGIBLE; new event type PAYMENT_REJECTED. Order responses now carry amountPaid / paymentStatus.

2026-07-25

Order intake, sales units

Added the sales-entry API (POST /raffles/{id}/orders + /preview, GET/PATCH orders, /raffles/{id}/purchasers) and the sales-unit registry (/sales-units). Orders are created complete and mint their draw numbers in the same transaction; clientRequestId makes a retried submit return the original order. New error codes ATTESTATION_REQUIRED, SALES_WINDOW_CLOSED, SALES_UNIT_UNKNOWN, PURCHASER_MATCH_FOUND; new event type ORDER_REJECTED.

2026-07-19

Ticket inventory & draw numbers

Added the reconciling ticket inventory (GET /raffles/{id}/ticket-inventory, /tickets, /tickets/{drawNumber}) and the two-person void control (/tickets/{ticketId}/void-requests, /void-requests/{requestId}/approve|/reject, GET /void-requests); new error codes INVENTORY_EXHAUSTED, TICKET_NOT_VOIDABLE, VOID_REQUEST_NOT_PENDING, VOID_APPROVAL_SELF. Issuance is internal-only (order intake); no new event types.

2026-07-16

Raffle configuration, tax jurisdictions

Added raffle configuration (/raffles + pricing plans, drawings, prizes, tax rate, compliance checklist) and the read-only tax-jurisdiction lookup (/tax-jurisdictions); new error codes RULE_VIOLATION, FEATURE_NOT_AVAILABLE, CONFIGURATION_LOCKED, TAX_RATE_FINALIZED, LICENSE_REQUIRED; new event type CONFIGURATION_LOCKED_ATTEMPT.

2026-07-15

Significant event log

Added the significant-event interrogation search (GET /events) and chain verification (POST /events/verification, GET /events/verification/latest); declared the full GLI-31 event-type catalog.

2026-07-13

Authentication & sessions, user administration

Initial API definition: conventions, roles, auth (/auth/*) and user administration (/users) endpoints.

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, approvedByIdentity and actorIdentity beside the user ids they already return, in the form Full 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 /users stays System Administrator only.

  • Void requests carry their ticket’s drawNumber. Ticket list rows carry seriesCode, issuedAt and voidedAt, and a ticket lookup its seriesCode.

  • The refund-request list populates orderReference on every row and voidedTicketCount and voidedDrawNumbers on approved rows, which until now appeared on the approval response only.

  • cfoil shows the identities, draw numbers, series codes and order references in its tables and record views. An earlier cfoil keeps 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}/approve and …/reject require note.

  • POST …/refund-requests/{id}/approval and …/rejection require note.

  • POST …/destructions/{id}/approval requires note and an explicit approve. A missing approve was 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 with 400 VALIDATION_ERROR naming the field. Nothing changes and no event is emitted.

  • cfoil’s resolve commands require `--note, and cfoil destruction approve requires --approve or --reject.

Every irreversible act now records why it was taken:

  • POST …/drawings/{id}/pool/closure requires note.

  • POST …/tax-worksheet requires note, which is now stored and returned as finalizationNote. Until now it was accepted and discarded.

  • POST …/drawings/{id}/counterfoil-batches requires reason in every mode, not only REPLACEMENT.

  • POST …/payments/{id}/reversal requires note beside the coded reason.

  • POST …/sessions/{id}/closure requires closingNote. When a prize went unawarded, the refusal still names the prizes (details.unawardedPrizeIds) and now also closingNote.

  • cfoil pools close, tax finalize, counterfoils generate, raffles payments reverse and drawings close require --note, --reason or --closing-note.

Database
  • V13 adds the nullable tax_worksheets.finalization_note. It sits outside content_json and content_hash, so no frozen worksheet’s hash, PDF or export changes, and nothing is backfilled. The archive’s reports/tax-worksheets.csv gains 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 --reason on 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 POST declares 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 UI line.

  • One runtime library, JMustache, and one test-only library, jsoup; both listed in Appendix D.

Security
  • The session travels in a __Host- cookie that is Secure, HttpOnly and SameSite=Strict. Every form POST is checked for its origin and for a synchronizer token derived from the session, with nothing new stored. /api/v1 still authenticates from the Authorization header alone, so a cookie-only API request is 401.

  • 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=false exists for http://localhost development only and logs a warning.

Fixed
  • GET and PATCH /api/v1/raffles/{id}, PUT …/tax-rate and GET …/compliance-checklist answered 404 over HTTP, so cfoil raffles get, update, tax-rate and checklist failed. The receipt and retention resources were mounted at /raffles/{raffleId}, which JAX-RS ranks above RaffleResource’s `/raffles and then searches alone. They are now mounted at /raffles and merge with it; no URL changed.

Deployment
  • The server honours X-Forwarded-Proto and X-Forwarded-For from 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=none turns it off.

  • COUNTERFOIL_UI_ENABLED defaults to true. Setting it to false and restarting removes the interface with no effect on the API, cfoil or 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-Id header, 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=ecs emits 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_POOL and 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 --verbose now prints elapsed time and the server’s X-Request-Id alongside 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 DEBUG rather than INFO, 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 at DEBUG. A 4xx is the system working.

  • PrincipalCleanupFilter moved from @Priority(Integer.MAX_VALUE) to Integer.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.bind is pinned to WARN with a warning in place and deliberately given no environment variable. At TRACE it prints bound statement parameters — every purchaser name and address — past every other control in this release.

  • A failed login records userId when the account resolved and the literal UNKNOWN_PRINCIPAL when 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 from org.counterfoil. All 1,016 source files across the three Gradle modules moved together, along with the group coordinate, the cfoil CLI’s mainClass, and the logging.level key in application.yml (now logging.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 read src/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. test now runs the unit and architecture tiers (1,524 tests, seconds, no Docker); the new integrationTest task 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 into check, so gradle build still means the whole suite. The split is by class-name pattern (*/*IT.class) over the one test source set rather than by a separate source set, because the dev.cfoil.support fixtures 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 spotlessApply reformats and spotlessCheck runs inside check. 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 root build.gradle for 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_paid is a cache of SUM(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’s UNIQUE (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.updateTaxRate has refused with TAX_RATE_FINALIZED since 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_CLAIMED unless 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-format Gradle module shared by the server and cfoil — 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_on is it. NOT_COMPUTABLE is 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_DESTROYED is 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 from organization.json.

Changed
  • GET /raffles/{id}/winner-register gains 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-jurisdictions are new and SYSTEM_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.

  • EventCanonicalizer and EventHasher moved to :archive-format so 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_FILE must 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-profile is the check. state must be WA: R12 requires records maintained in Washington, and the server refuses to start otherwise.

  • V12__recordkeeping_tax_retention.sql adds 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-back cfoil renders RECORDS_DESTROYED as a raw string.

Known gaps
  • The archive passphrase is unrecoverable. No escrow, no hint, no reset — and no QUEUED export 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.

  • RetentionDestructionPort is 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_value must 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_date is what the statement speaks to, recorded_at what the clock says.

  • The real R11 check, replacing the placeholder. PreliminaryReserveCheck is deleted and ReserveCheckService bound in its place; ReserveCheckPort is unchanged, so no line of draw/ 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_AVAILABLE and FAILED kept strictly apart. FAILED means the check was performed and the funds are short. NOT_AVAILABLE means 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 as FAILED would 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 UNIQUE index, 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
  • SessionOpenGate gains three non-overridable blockers — PRIZE_RECORD_REQUIRED, PRIZE_DOCUMENTATION_REQUIRED and ALL_PRIZES_AWARDED — reaching it through PrizeReadinessPort. 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.award opens the fulfilment record through WinnerFulfilmentPort, in the same transaction. One additive call; the dependency direction is unchanged.

  • The reserve figures a session freezes change meaning. reserve_cash_prize_total now excludes noncash prizes and spans the day’s drawings, reserve_recorded_balance and its source document stop being null once a balance is on file, and reserve_status can be PASSED — so reserve_overridden should start reading false. 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_json area 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_balances is 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_method and raw_input are NOT 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_by enforced by a CHECK constraint. 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, WITHDRAWN and ALREADY_DRAWN are 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_json is the record; the PDF and CSV renderers are pure functions of it with dates and /ID from 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 reports NOT_AVAILABLE — it never returns PASSED, 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}. draw is 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_at and Module 6’s as-of ledger form, never the cached payment_status column. Given the same instant it returns the same set today and in the 2029 audit.

  • Pool closure: one irreversible freeze per drawing. OPEN is the absence of a drawing_pools row and CLOSED is its existence, so UNIQUE (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_id is 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: null distinguishes "closed under a superseded rule version, not reproducible by design" from "corrupt".

  • cfoil pools {list\|show\|members\|close\|verify}. close prints 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. DrawingPoolPort is unchanged and no line of counterfoil/ changed; Module 8’s entire integration tier now runs against the real engine, which is the reconciliation its spec promised.

  • MULTIPLE_TICKET counterfoil barrels can now be printed. They previously returned FEATURE_NOT_AVAILABLE.

  • Counterfoil batches cut against a closed pool now record poolBasis: CLOSED with a real poolId — the first time that column has been anything but PRELIMINARY.

  • counterfoil_batches.pool_id becomes a real foreign key now that drawing_pools exists.

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 poolAsOf alone.

  • 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_TICKET pools return FEATURE_NOT_AVAILABLE until Module 9, the precedent Module 3 set for REFER_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_80 is the reference — a receipt printer with a partial cut between stubs, which is exactly the tear-off artifact 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/PDF_A4 print 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 payload CF1:<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 /ID from 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 readyForDrawing predicate Module 10 will gate a drawing session on. printedNotInPool is 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. print opens 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 exits 40 and 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. Migration V8__counterfoils.sql adds counterfoil_batches, counterfoils and counterfoil_print_runs; it alters nothing existing.

Changed
  • DrawingRepository gains a FOR UPDATE read 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 ApiClient gains a streaming download beside the buffering one, and a profile gains a default printer target (cfoil config set-profile --printer …, or COUNTERFOIL_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_type filter; it is not a delivery obligation like R9. An unbalanced verification is a REPORT_GENERATED carrying balanced: 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 printedNotInPool alarm.

  • Every batch records poolBasis: PRELIMINARY and readyForDrawing is never true, because pool closure arrives with Module 9. The blockers list 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. PaymentEligibilityPort is 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 /ID come 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; emailedAt stands, 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_FILE is 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 /organization is 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 type RECEIPT_DELIVERY_FAILED; cfoil gains raffles receipts, raffles print-batches and organization.

Changed
  • A raffle can no longer start selling without a licensee on file. lockForFirstIssuance gains 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 as ORDER_REJECTED with the new reason ORGANIZATION_NOT_CONFIGURED. The pre-lock compliance checklist gains an ORGANIZATION line item so this is discovered days before sales day rather than at the counter.

  • raffle gains organization_profile_json / organization_profile_hash, frozen by the same atomic UPDATE that performs DRAFT → 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_NEW transaction was silently discarded when the outer transaction rolled back — which is the one job those emitters exist to do. ChainedEventWriter buffers events on a TransactionSynchronizationManager resource, 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 implements suspend()/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_REJECTED and CONFIGURATION_LOCKED_ATTEMPT were 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_key is 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_number as readily as on uq_receipts_current_order; ReceiptIssuanceCoordinator recognized only the latter. It now recovers from either, since both express the same fact.

Deployment
  • Write organization.json and restart before anything else. Without it no raffle can issue a ticket. Verify GET /organization returns configured: true. Unlike COUNTERFOIL_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-LOCKED raffle 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-sweep bounds the drain; decide with the operating org whether historical purchasers should be emailed at all (?deliver=NONE issues 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 be https — 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 — payments is append-only and signed, with exactly three entry types: PAYMENT (positive), REVERSAL and REFUND (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 no PATCH and no DELETE on 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 REFUND entry, voids every remaining ticket through Module 4’s new OrderTicketVoidPort, 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 own ticket_void_request row 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; PARTIAL is not partial eligibility, it is no eligibility. Two forms: the materialized payment_status for live queries, and an as-of form recomputed from the ledger’s recorded_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_last4 is four characters, and PanGuard rejects 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. The heldAgainstPartialOrders bucket 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-labelled netReceiptsProvisional with the Module 11/12 boundaries carried on the response.

  • Unpaid work queue — ?paymentStatus=UNPAID|PARTIAL on 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 type PAYMENT_REJECTED (a refused payment leaves no row, so without it the refusal would be invisible to interrogation — and CARD_DATA_DETECTED is a security fact a GLI lab looks for by filter).

  • cfoil gains raffles payments record\|reverse\|ledger\|list, raffles refunds request\|approve\|reject\|list, raffles reconciliation, and raffles financial-summary.

Changed
  • raffle_orders gains amount_paid, payment_status, paid_on/paid_at, refunded_on/refunded_at and payment_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 carry amountPaid, paymentStatus and ticketsEligible.

  • VoidReason gains REFUND (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_request gains a nullable refund_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 SUM served 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 at READ_COMMITTED, so the recomputation sees everything committed before the lock. Found by PaymentLedgerIT.

  • A payment could be reversed more than once, double-subtracting and driving the balance negative. PAYMENT_ALREADY_REVERSED now 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.sql is additive and forward-only. Existing orders default to payment_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 correct payment_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 V6 schema is safe and lossless — the older build ignores the new tables and columns, and OrderStatus.VOIDED already 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}/orders validates channel, sales window, residency, attestation and pricing, resolves or creates the purchaser, writes the order and its lines, and mints its draw numbers through DrawNumberIssuancePort in 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 returns 200 OK with the original order rather than minting a second irreversible range; concurrent duplicates are arbitrated by the unique key, with OrderPlacementCoordinator returning the winner’s order to the losers.

  • Server-side pricing (R3, R18) — the request names a pricingPlanId and a quantity; no request DTO in the module has a price or amount field. Each raffle_order_lines row 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) — ResidencyPolicy with the v1 WashingtonResidencyPolicy (state WA, postal code 98001–99403), rejecting with RULE_VIOLATION / rule: "P1". A code seam, not a YAML toggle, on the same principle as RegulatoryLimits; 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 held FOR UPDATE so 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_FOUND with candidates, resolved in one further round-trip by either purchaser.purchaserId (reuse) or acknowledgeDuplicate: true (which stamps duplicateAckOfId, 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_units registry (/sales-units, System Administrator) and a sales_unit_id on every order. WORKSTATION is the only type registrable in v1; RSU (L2) and VENDOR_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/preview returns totals, per-line R18 rates, sales-window state and duplicate candidates, taking no lock and writing nothing.

  • Raffle-scoped purchasers — /raffles/{id}/purchasers search, read and correction. Scoping purchasers to the raffle keeps per-raffle retention (R12) and the Module 12 archive clean cuts.

  • Order corrections — PATCH exposes sourceReference and note only. Changing what was sold goes through Module 4’s two-person void, not an edit.

  • ORDER_REJECTED significant event — committed in REQUIRES_NEW so the audit trail of a refused sale survives the rejected transaction’s rollback, with reason one of SALES_WINDOW_CLOSED, RESIDENCY, ATTESTATION, INVENTORY_EXHAUSTED, LICENSE_REQUIRED.

  • PII masking for purchaser data — PiiMasker.maskName / maskPhone join the existing maskEmail; 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 the ticket.order_id → raffle_orders foreign key Module 4 deferred to this release.

Changed
  • ticket.order_id is now a real foreign key. A synthetic order id is no longer insertable — issuance called outside an order must pass null, as DrawNumberIssuancePort documents.

  • Module 5’s tables are named in the plural (sales_units, purchasers, raffle_orders, raffle_order_lines), matching users; Modules 3 and 4 remain singular. The split is flagged in the tech spec for ratification rather than silently reconciled.

  • RoleSegregationArchitectureTest allowlists /sales-units as 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 totalAmount but no payment state, and ticket eligibility on payment arrives with that module.

  • orderReference is derived as ORD-{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 under SELECT … FOR UPDATE, so the sequence is gapless by construction (a rolled-back order reverts the counter) — deliberately not AUTO_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; the FOR UPDATE serialization means it is never hit in practice.

  • The pre-sale lock trigger, co-located with issuance (R5): issueTickets calls RaffleConfigLockPort.lockForFirstIssuance as 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_EXHAUSTED roll the order back).

  • Multi-series model — ticket_series (named series, distinct prefixes, independent consecutive sequences). v1 auto-provisions exactly one active ENHANCED series per raffle (TicketSeriesProvisioner, idempotent, sized from raffle.total_ticket_count). REFER_A_FRIEND is 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 to VOID — the number is kept, never freed, and remains permanently visible in inventory and reports. GET /api/v1/raffles/{id}/void-requests is the work queue. approver ≠ requester is enforced in the service, beyond the role gate.

  • Reconciling inventory — GET /api/v1/raffles/{id}/ticket-inventory: per-series and totals with unissued + issued + voided = total and an explicit reconciles assertion, 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 by status, seriesCode, voidPending) and GET /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 exclude AUDITOR and SYSTEM_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; the ticket.order_id FK 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).

  • cfoil reference CLI (0.4.0): raffles tickets {inventory,list,get} and raffles 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), and RECORD_MODIFY (a void-request rejection) event types — no new event types. The first sale continues to fire Module 3’s CONFIGURATION_LOCK, now triggered by this module’s issuance.

Notes
  • A pending void does not change ticket eligibility; only an approved VOID excludes a ticket from drawing pools (Module 9 keys on status = VOID). Pending requests are surfaced as voidPending in inventory so they are never invisible.

  • The VoidReason controlled 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=true shows 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_FRIEND is 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_RECEIPTS is 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-way DRAFT → LOCKED via an atomic conditional UPDATE (idempotent under concurrent first sales; exactly one CONFIGURATION_LOCK event); every configuration mutation serializes against it through a SELECT … FOR UPDATE guard. There is no unlock and no manual-lock endpoint; post-lock mutation attempts return 409 CONFIGURATION_LOCKED and 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_at seam 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 (with error.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 (default America/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_LOCK event types Module 2 declared, plus RECORD_CREATE for configuration records — all through the unchanged SignificantEventPort.

  • 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 (ChainedEventWriter buffers per transaction and flushes at commit; EventChainAppender serializes appends on a singleton chain_head row 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 own EVENT_CHAIN_VERIFIED / EVENT_CHAIN_BROKEN significant 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 (inline 200, or 202 with a run id for large chains; System Administrator only) and GET /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/DELETE reject 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_STARTUP significant event emitted on application readiness.

  • Configuration: counterfoil.events.verification.{cron,on-startup,inline-max-events} and counterfoil.events.search.max-page-size.

Changed
  • Flyway migration V2__event_chain extends significant_event with the chain columns, adds the significant_event_chain_head and event_chain_verification tables 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/verification is 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 via verification/latest and 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 @RolesAllowed with 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 is disable (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 AuditableEntity is stamped with the authenticated actor and an authoritative-Clock timestamp; a write with no principal fails closed (no unattributed records).

  • SignificantEventPort seam 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), the cfoil distribution, or the archive-format jar 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

com.fasterxml.jackson.core:jackson-databind

2.21.4

Apache-2.0

cli

runtime

com.fasterxml.jackson.datatype:jackson-datatype-jsr310

2.21.4

Apache-2.0

cli

runtime

com.mysql:mysql-connector-j

9.7.0

GPL-2.0-only WITH Universal-FOSS-exception-1.0

server

runtime

com.samskivert:jmustache

1.16

BSD-3-Clause

server

runtime

info.picocli:picocli

4.7.6

Apache-2.0

cli

runtime

org.apache.pdfbox:pdfbox

3.0.8

Apache-2.0

server

runtime

org.bouncycastle:bcprov-jdk18on

1.78.1

Bouncy Castle Licence (MIT-style)

server, archive-format

runtime

org.flywaydb:flyway-mysql

12.10.0

Apache-2.0

server

runtime

org.springframework.boot:spring-boot-starter-data-jpa

4.1.0

Apache-2.0

server

runtime

org.springframework.boot:spring-boot-starter-flyway

4.1.0

Apache-2.0

server

runtime

org.springframework.boot:spring-boot-starter-jersey

4.1.0

Apache-2.0

server

runtime

org.springframework.boot:spring-boot-starter-json

4.1.0

Apache-2.0

server

runtime

org.springframework.boot:spring-boot-starter-validation

4.1.0

Apache-2.0

server

runtime

org.springframework.security:spring-security-crypto

7.1.0

Apache-2.0

server

runtime

com.diffplug.spotless

8.9.0

Apache-2.0

server

build-time

com.google.googlejavaformat:google-java-format

1.36.1

Apache-2.0

server, cli, archive-format

build-time

info.picocli:picocli-codegen

4.7.6

Apache-2.0

cli

build-time

io.spring.dependency-management

1.1.6

Apache-2.0

server

build-time

org.asciidoctor.jvm.convert

4.0.4

Apache-2.0

server

build-time

org.asciidoctor.jvm.pdf

4.0.4

Apache-2.0

server

build-time

org.asciidoctor:asciidoctorj

3.0.0

Apache-2.0

server

build-time

org.asciidoctor:asciidoctorj-pdf

2.3.19

MIT

server

build-time

org.assertj:assertj-core

3.26.3

Apache-2.0

cli, archive-format

build-time

org.jruby:jruby-complete

9.4.13.0

EPL-2.0 OR GPL-2.0 OR LGPL-2.1

server

build-time

org.jsoup:jsoup

1.21.1

MIT

server

build-time

org.junit.jupiter:junit-jupiter

5.11.4

EPL-2.0

cli, archive-format

build-time

org.junit.platform:junit-platform-launcher

1.11.4

EPL-2.0

cli, archive-format

build-time

org.junit:junit-bom

5.11.4

EPL-2.0

cli, archive-format

build-time

org.springframework.boot

4.1.0

Apache-2.0

server

build-time

org.springframework.boot:spring-boot-starter-test

4.1.0

Apache-2.0

server

build-time

org.springframework.boot:spring-boot-testcontainers

4.1.0

Apache-2.0

server

build-time

org.testcontainers:testcontainers-junit-jupiter

2.0.5

MIT

server

build-time

org.testcontainers:testcontainers-mysql

2.0.5

MIT

server

build-time

D.2. Runtimes

Runtime Version Used for

Java (JDK)

25

Builds and runs the server, cfoil and the archive verifier.

MySQL

9.7

The server’s database.

Gradle

9.4.1

Build only. There is no committed wrapper; use the system gradle.

Google Chrome (desktop)

Current stable release

The administration web interface at /ui/.

Mozilla Firefox (desktop)

Current stable release

The administration web interface at /ui/.