# Snapshot and API contract, version 1 The backend is C++23 using Ligo HTTP and Noto/SQLite. `fpol_admin` and HTTP routes call the same domain functions. SQLite migration `001_snapshots` stores immutable JSON documents with typed metadata, catalog foreign keys and an import-time validator. This deliberately small persistence model preserves source fields and history; normalized league/draft/trade tables come with multiplayer. ## Import format See the runnable documents in [data/demo](../data/demo). All documents require: - `schema_version: 1`, a unique `id`, `kind` (`catalog`, `ratings`, `results`), `mode` (`demo`, `real`), `label`, and `as_of`. - `source: {name, url, retrieved_at}`. Real sources require an HTTP(S) URL. Only demo sources may use `fixture:` URLs. - UTC timestamps in `YYYY-MM-DDTHH:MM:SSZ` form. An adapter must normalize provider offsets, never invent observation times. `as_of` is the source observation time, `retrieved_at` the collection time. Older snapshots imported later keep their earlier position. A catalog has a `complete` boolean, `races` and `candidates`. Each race needs `id`, `name`, `office` (`house`, `senate`, `governor`), two-letter `state`, and `round`. Each candidate needs `id`, `race_id`, `name`, `affiliation` (`democrat`, `republican`, `other`), `qualified: true`, `ballot_lines`, `provider_ids`, and `source`. IDs describe candidacies, not a person's name. Provider mappings are separate from those IDs. A provider ID may identify only one candidate. Qualification and fusion reconciliation are curator responsibilities; the validator checks structure, identities and explicit qualification, not external truth. Ratings have `catalog_id` and `entries: [{race_id, rating}]`, where `rating` is `safe`, `likely`, `lean`, or `toss_up`. Missing entries stay unknown. New rating editions receive new IDs; a preview references exactly one edition. No draft exists yet to enforce a league's pre-draft snapshot selection. Results have `catalog_id` and `entries`, each containing: ```json { "race_id": "demo-house-01", "round": "general", "status": "reporting", "total_votes": 1000, "winner_id": null, "votes": [ {"candidate_id": "demo-house-01-1", "votes": 510}, {"candidate_id": "demo-house-01-2", "votes": 490}, {"candidate_id": "demo-house-01-3", "votes": 0}, {"candidate_id": "demo-house-01-4", "votes": 0} ] } ``` Statuses: `reporting`, `called`, `certified`, `pending_round`. Only called/certified rows have `winner_id`. `total_votes` may be null; candidate counts are nonnegative integers bounded at one billion, their sum cannot exceed the total. Totals may include nondraftable write-ins; those still belong in the vote-share denominator. An absent candidate row is unknown, not zero. Each document is a complete snapshot of its included race data, not a delta: omitted races do not inherit previous votes. Preserve per-race source timestamps as additional fields when a future adapter combines sources; the current fixtures use one observation time per document. Duplicate races, duplicate candidate counts, foreign-race candidates, conflicting modes, unknown references and mismatched rounds are rejected. Combine fusion ballot counts explicitly upstream, retaining original evidence. Missing or pending-round scores are returned as null. Runoff/RCV progression is a launch blocker, not inferred from reporting status. An identical repeat import is a no-op; conflicting content under the same ID fails. Corrections get a new ID and observation time, including call retractions. A single document insertion is atomic. Multi-file CLI imports commit each file independently in argument order, so import catalogs before dependent snapshots. Persisted history is never updated by the importer; replay requires no rollback writes. Administrators should run one importer at a time; simultaneous identical imports can produce a uniqueness error that is safe to retry. ## Scoring and roster semantics `(100 × candidate_votes / total_votes + awarded_win_bonus) × race_multiplier`. The awarded bonus is 50 only for the explicit called/certified winner; other candidates receive zero. Multipliers are Safe 1.00, Likely 1.10, Lean 1.25, Toss-up 1.50. Integer arithmetic rounds each candidate half up to two decimals before summing. More votes for a candidate with opponents' counts fixed cannot reduce their score. Shares can still fall as opponents receive votes; corrections can lower totals or retract bonuses. Every office has one D, R, Other and Flex slot. Flex accepts any affiliation within that office. A candidacy may occupy only one roster slot; opponents may both be rostered. Partial rosters are valid for exploration but have no complete total. Cross-team ownership, draft locks and trade enforcement belong to the multiplayer milestone. `leader_preview_points` is hypothetical: only an untied leader with all catalog candidate rows present and all votes accounted for receives a hypothetical bonus, and an explicit call always takes precedence. It is separate from awarded points. A tied or incompletely reported lead has a null preview; `unavailable_leader_previews` counts gaps, and the preview subtotal only includes available previews. Missing scores do not become zero; `known_subtotal` sums only available scores and `missing_scores` reports gaps. `total` exists only when all twelve slots have valid, available scores. A complete total can still be provisional during reporting. ## HTTP All responses use Ligo's `{ok: true, payload: ...}` or `{ok: false, code, message}` envelope. Invalid input is HTTP 400; roster eligibility errors are a successful validation response with `valid: false`. Unknown routes are 404. No import/write/admin route is exposed. | Method / path | Request | Payload | | --- | --- | --- | | `GET /api/health` | None | `{status: "ok"}` process liveness | | `GET /api/rules` | None | Version, slots, multipliers, formula and rounding | | `GET /api/catalog` | None | `{documents: [...]}`, ordered by `as_of`, then ID | | `POST /api/capacity` | `{catalog_id, players}` | Per-office party and total shortages | | `POST /api/score-preview` | `{catalog_id, ratings_id, results_id, roster: [{slot, candidate_id}]}` | Validation, breakdown, baseline and leader preview | The complete document response is intentionally suitable for this small prototype; pagination/indexed views will be needed as real replay history grows. Exact routes have no query-string normalization; the browser filters locally. Imported data becomes visible on the next request without restarting. The browser loads its catalog snapshot on page load; refresh after imports. No authentication is implemented yet; use local development until the multiplayer access controls exist. ## Compatibility with league scoring The APIs and immutable documents above are the original **v1 development simulator**. Their `pending_round` preview behavior remains unchanged for replay compatibility. They are available over HTTP only with `--allow-demo`; CLI imports/previews remain available to the local operator. Private leagues use the [application API](application-api.md) and separate [count/outcome observations](ingestion.md). Their frozen rule version is `v2-november-share`: November first-round/first-choice share continues to score during a pending runoff, and only the eventual winner bonus follows later-round outcomes. Do not use a v1 preview total as a v2 league standing. Commissioners can import a sourced ratings document through `POST /api/league/ratings` while a league is forming; it uses the existing immutable ratings schema and must reference that league's selected catalog.