Event Catalog & Canonical Serialization #

Companion to architecture.md (stream store, lineage), state-layers.md (every transition names an event from this catalog), and flows.md. This doc resolves the storage contradiction (JSONL vs canonical bytes), defines the one canonical encoding every hash in the system is computed over, and catalogs every event type per stream.

Contents:

  1. The storage decision
  2. Performance envelope — why this is never the bottleneck
  3. Canonical encoding
  4. Record envelope and segment layout
  5. Durability policy
  6. Event catalog by stream
  7. Schema evolution rules
  8. Projections: JSONL (debug) and Parquet (analytics)

1. The storage decision #

Canonical binary is the source of truth. Projections — JSONL (debug), Parquet (analytics) — are derived and discardable.

The earlier "append-only JSONL / SQLite" phrasing in architecture.md was a contradiction waiting to bite: hash chains are computed over canonical_bytes(event), and JSON has no canonical bytes — key order, number formatting, and unicode escaping all vary by serializer. Two correct implementations could disagree on every hash in the system.

So the store is:

  • Truth: append-only segments of canonically-encoded binary records (§3, §4). This is what hash chains cover, what the determinism harness diffs, what DA layers receive, and what proofs commit to.
  • Projections: derived, read-only renderings (§8) — JSONL for grepping, debugging, and human eyes; Parquet for the analytics/research side. Like snapshots: derivable, discardable, never authoritative, never read back.

This also answers "external data will land here": every source — demo files, broadcast fragments, licensed feeds — is normalized at the edge into this one encoding. We define the interior format; providers never do. A new feed is a new adapter emitting catalog events, not a new storage story.

2. Performance envelope — why this is never the bottleneck #

The question "can JSONL get performant enough?" dissolves once truth is binary, but the numbers are worth having on record:

  • Write path is sequential append — the single fastest thing storage does. NVMe sequential write is measured in GB/s; our events are hundreds of bytes. The demo scale (~4 Hz × ~10 streams × <1 KB) is on the order of tens of KB/s — roughly five orders of magnitude below the hardware ceiling. Even A live-cell scale (hundreds of events/sec × dozens of concurrent matches) stays in single-digit MB/s. Kafka-class systems push millions of events/sec with exactly this design; format choice is not what limits them.
  • The real write cost is durability, not encoding — fsync latency (~0.1–1 ms), not serialization. §5 shows why we only pay it where it matters.
  • The read path is where binary wins big: recompute-from-zero and the determinism harness re-parse entire histories. Binary decode is near-memcpy; JSON parsing is 5–10× slower and allocation-heavy. Replay speed is a developer-experience feature (bisecting, re-running, backtesting) — this is where the format choice actually pays.
  • Size: binary + integer-only encoding runs 2–5× smaller than JSON, which compounds into replay speed, snapshot transfer, and DA costs (DA bytes are literally priced).

Conclusion: JSONL would never bottleneck the system — but it would break hashing, and it would have taxed replay and DA forever. Binary truth fixes the correctness problem and removes the ceiling in one decision.

3. Canonical encoding #

Deterministic CBOR — RFC 8949 §4.2 Core Deterministic Encoding, further restricted. We adopt a standard rather than inventing a TLV format: the deterministic profile is specified by the RFC, mature libraries exist in Rust and Python (the parity pair we already maintain), and CBOR's data model maps 1:1 to our needs.

Restrictions on top of the RFC profile (all checked at append time):

  1. No floating-point values, anywhere. The system is integer/fixed-point by law; the encoder rejects floats outright.
  2. Definite lengths only; shortest-form integer encoding (both RFC-required).
  3. Map keys are unsigned integers (field ids from this catalog), sorted ascending — not strings. Smaller, faster, and renames become impossible representation changes.
  4. No tags, no undefined, no bignums. The allowed types: unsigned/negative integers, byte strings, text strings (UTF-8, NFC), arrays, maps, booleans, null. ("No bignums" names the CBOR bignum tags 2 and 3, which this rule bars with every other tag. An application-level arbitrary-precision integer carried as a declared byte-string field is not a tag and stays admissible — the allocation stream's rationals, §6.17.)
  5. One valid encoding per value — guaranteed by 1–4; the test suite round-trips every catalog type and asserts byte-identity between the Rust and Python encoders (same discipline as the model-artifact parity gate).

canonical_bytes(event) = the deterministic CBOR encoding of the record envelope (§4). All hashes are BLAKE3 over those bytes.

4. Record envelope and segment layout #

Every record on every stream is the same envelope (field ids in parentheses):

Record (CBOR map, integer keys):
 (0) schema_id : u32 — event type + version, from this catalog
 (1) replay_ts : u64 — replay-clock ticks (the only clock)
 (2) seq : u64 — tiebreak sequence within equal replay_ts
 (3) sleeve_id : u32 — 0 = system; reserved now per growth path D3
 (4) body : map — event-specific fields per §6
Offset is positional (record index in the stream) — never stored, never trusted from data.

Tick granularity note: worked examples across the docs display replay time in seconds (e.g. ts=3134.75) for readability; records store replay_ts as an integer tick count — floats are banned (§3 rule 1), so no fractional value ever appears in a record. The seconds↔ticks granularity (the source's tick rate) is pinned in the run manifest, alongside everything else two runs must agree on.

Segment layout on disk (per stream, directory of numbered segments):

segment-NNNNNNNN.d2s
 header : magic "D2SEG\0" (6B) ‖ format_version u16 ‖ stream_id (32B) ‖ base_offset u64 ‖
          prev_segment_end_hash (32B) ‖ lineage_len (u32 LE, ≤ MAX_LINEAGE_BYTES) ‖
 lineage_ref (the runtime's canonical CBOR map, one contract for its writers `runtime::streams::lineage_record` (keys 0–4) and `runtime::epoch_store::write_store_entry` (key 5) and its reader `futarchy::fold::returns` : {0: manifest_hash — the run id, 32 bytes; 1: root_parents — the root stream ids as bytes, the MATCH LOG first, the order log second, the control log third when the run has one; 2: transformer — text; 3: crate_version — text; 4: included_at — for a run citing an epoch allocation the epoch-log offset of its run_included row, ABSENT otherwise so every earlier run's stream ids stand; 5: epoch_id — 32 bytes, the ALLOCATION stream's one key (`LN_EPOCH_ID`): a store entry's allocation stream (§6.17) carries the one-key map {5: epoch_id}, written by `runtime::epoch_store::write_store_entry`, the run streams' keys 0–4 unchanged — one epoch, one allocation stream id, futarchy-system.md §11 W13}; a derived stream's id is `derive(name, lineage_ref)`, so a reader re-derives the id before trusting the record)
 records: [u32 length-prefix | record bytes] ...
 footer : sentinel u32::MAX, record_count, end_hash h(last), state_root flag u8 (+ 32B when 1 — RESERVED: every production writer seals it 0) (written at seal)
  • Hash chain: h(i) = BLAKE3(h(i−1) ‖ record_bytes(i)), seeded from prev_segment_end_hash — chains span segments.
  • A record's length prefix is at most MAX_RECORD_LEN = 16 MiB (stream-core's segment constant); a longer prefix is refused at write, at read and at a live tail poll (stream-core's writer, reader and tail reader), and on crash recovery it ends the valid prefix the torn tail is truncated to (next bullet) — parse hardening, the backstop every decoder of recorded text stands on (gateway-protocol §7).
  • A header's lineage_len is at most MAX_LINEAGE_BYTES = 16 MiB (stream-core's segment constant, equal to MAX_RECORD_LEN); a longer declaration is refused at write, at a tail open before the lineage bytes are read, and at a whole-file read before they are parsed.
  • A torn tail (crash mid-append) is detected by the length-prefix + chain and truncated at the last valid record on recovery; for derived streams the tail is recomputed, for root logs the fsync policy (§5) guarantees the truncated record was never acknowledged.
  • Sealed segments are immutable files — the unit of snapshot, transfer, and DA publication.
  • Header certification is a reader/registry obligation, not a chain property: the chain domain is record bytes only, by design. The consuming layer must (1) re-derive stream_id from (name, lineage record) and reject mismatches, (2) verify cross-segment chain links (prev_segment_end_hash equals the predecessor's end hash; first segment anchors to the zero genesis), and (3) verify base-offset continuity (base = prev.base + prev.count). A lone segment reader cannot perform these checks — the lineage registry layer owns them.

The container is exactly this segment-file layout — no database engines (SQLite is out). The spec binds bytes and semantics; the files above are the one authoritative implementation. What is never acceptable is JSON — or any columnar/batch format — as truth: truth needs record-level append, fsync-before-ack, and byte-stable records for the hash chains. Everything else is a projection (§8).

5. Durability policy #

The fold architecture splits durability needs cleanly:

Stream class Durability Why
Root logs (per-title match logs — §6.1, §6.11 — order log, control stream, epoch log §6.16) fsync before acknowledgment (group-commit allowed within a tick) They are the impure edges — the only bytes that cannot be recomputed. An order acknowledged to a user must survive any crash
Derived streams (everything else) write-back, lazy fsync They are caches of a deterministic fold — a crash loses nothing; recovery recomputes from roots + last snapshot

This is the performance answer with teeth: the expensive syscall is paid only on the root logs, at human/feed event rates (not tick rates), while the high-volume derived streams ride the page cache. Recovery cost is bounded by snapshot cadence, not history length.

6. Event catalog by stream #

Every event type, its owning stream, and its body fields. Types are name/v1; the schema_id is the registry index of (name, version). The tables below omit the registry prefix ("cs2/", "sys/", …) for readability — the registered name always carries it per §7 rule 4. All quantities are integers per the fixed-point conventions (model-plugin-layer.md §3); prices are integer mills ($0.001 — the 0.1-cent tick grid, 0..1000; see matching-engine.md §1), sizes are claim units, money amounts are integer mills of play-money USDC.

6.1 CS2 match log (root) #

Normative body field ids: while the pinned registry FILE (§7 rule 5) has no producer — external clients are the first to need a registry outside the codebase; the run manifest ships without it — the field ids for these events are the RS_*/K_*/B_*/RE_*/RR_*/TS_* constants (non-exhaustive) in crates/ingest/src/cs2/events/ — code is the id registry of record, and §7 rule 3 (an id is never reused for a new meaning; carrying an id forward under a new schema_id, as tick_state/v2 does with TS_*, is continuity, not reuse) is audited there.

Event Body fields
match_discovered/v1 match_id, source_ref (source-defined provenance hash: demo hash, broadcast url hash, or the §6.12 rule-2 table digest), map
match_live/v1 match_id
round_start/v1 round_no, t_slots[], ct_slots[] — flat per-side slot arrays; sides may be shorthanded (< 5 slots) per SC-3, so no fixed cardinality
tick_state/v1 players[]: {slot, hp, armor, weapon_id, x, y, z, alive}, bomb_state, clock_ticks
tick_state/v2 players[]: {…the eight v1 fields…, pitch, yaw, delta_pitch, delta_yaw (aim, degrees ×256), velocity_x, velocity_y, velocity_z (map units per second ×256), buttons (raw input bitmask)}, bomb_state, clock_ticks — the enriched snapshot feeding dust2/v2. Angles/velocities are fixed-point integers (floats banned interior-wide, §3 rule 1); the float→fixed-point quantization happens at the ingest edge — the enriched opencs2-edge adapter, whose derivation is §6.12 rule 6b. The per-player array grows from 8 to 16 elements, the eight v1 fields carried forward at the same indices (§7 rule 3). A NEW version, never an amendment (§7 rule 1): tick_state/v1 and the models trained on it (gbm_v1) stay reproducible against the original byte shape. The buttons bit vocabulary (attack/jump/duck/move) is decoded by the feature layer (dust2/v2), opaque to the canonical event
kill/v1 attacker_slot, victim_slot, weapon_id, headshot, attacker_team, victim_team — teams populated at ingest by joining slots against the round_start/v1 roster, so settlement predicates (settlement-contract.md SC-1) read kill events alone, with no runtime join. attacker_slot and attacker_team are null (together, never singly) when no player attacker exists — C4, fall, world deaths credit no one per SC-1
bomb_event/v1 kind: planted / defused / exploded, site, slot
round_end/v1 round_no, winner, reported_kills_t, reported_kills_ct (flat fields, named for what they are: a diagnostic cross-check per SC-1 — recomputed truth is the kill stream), finality: provisional / canonical
watermark/v1 complete_up_to_ts
revision/v1 supersedes_offset, corrected_schema (the corrected event's schema id — an untyped body would be undecodable), corrected_body
round_restored/v1 round_no, superseded_from_offset
match_complete/v1 match_id, finality: canonical

The settlement contract (teamkill rule, C4/fall deaths, post-round kills) binds to kill/v1 and round_end/v1 semantics and is pinned in the preflight report — its open decisions are tracked there, not here.

The match log may also carry the three universal sys/ feed-health records — sys/feed_watch_armed/v1, sys/feed_stalled/v1, sys/feed_recovered/v1 (§7 rule 4) — the live capture's, under §6.13 rule 12 (d), read by the run's feed fold alone (risk-engine.md §3 R7, §11).

6.2 Game-state stream (derived from match log by Replay) #

Event Body fields
state/v1 round_no, per-team {kills, alive, hp[], armor[], weapons[], positions[]}, bomb_state, clock_ticks — offset of this stream is state_version
state/v2 round_no, per-team {…the six v1 fields (kills, alive scalars + hp[], armor[], weapons[], positions[])…, pitch[], yaw[], delta_pitch[], delta_yaw[], velocities[] (of [vx, vy, vz] triples), buttons[]}, bomb_state, clock_ticks — derived from tick_state/v2, consumed by the dust2/v2 feature extractor. Parallel, slot-ordered arrays exactly as v1; the enriched arrays keep the v1 team-map field ids TM_KILLS..=TM_POSITIONS (carried forward per §7 rule 3) and ride TM2_* ids above them. A NEW version (§7 rule 1); state/v1 byte shape unchanged
forwarded events round_end, round_restored, watermark, revision pass through with original bodies — revision (§6.13 rule 11): a §7 rule-6 correction audit whose actual state change (when one exists) is carried by a separately-appended round_restored on the match log, never by revision itself; the game-state fold treats revision as a pure no-op (visible on the stream, no effect of its own)

6.3 Feature stream (derived by Feature Extractor) #

Event Body fields
features/v1 state_version, feature_schema_id, team, vector: i32[] on the schema grid

On a BOARD (model-plugin §12) the ONE feature stream carries several grids: per driving state, one record set per board column in the board's canonical order (ascending family text), each record self-describing through feature_schema_id (the column's grid); a model consumes only the records carrying its own column's grid (state-layers §4). The cross-column record order is normative — it is the bytes a hash chain covers.

6.4 Probability streams (derived by model wrappers; live + shadow) #

Event Body fields
distribution/v1 state_version, team, model_id (32B), wrapper_version, p: u32[n] summing to exactly 65536 — n = the producing model's n_classes = the target market family's outcome-class count, 1..=8 (MAX_CLASSES; 6 on the CS2 kills family, 2 on a match-winner family; the model side of that equality is 2..=8 — model-plugin-layer §4 refuses a one-class artifact at parse — while the wire type's floor stays one pending its own ruling: no title's vocabulary has a one-class family, so no record of width 1 is produced); decode bounds the width BEFORE reading entries; the quote fold halts a distribution whose width is not its market's (a 6-wide record is byte-identical to before)

6.5 Quote streams (derived by bookmakers; per sleeve) #

Event Body fields
quote_batch/v1 state_version, market_id, cancel_all_prior: bool, levels[]: {side, price_ticks, size}

6.6 Order log (root) #

Event Body fields
order/v1 account_id, market_id, side: higher / lower, action: buy / sell, size, tif: ioc, quoted_version
account_created/v1 account_id, initial_balance
forecast_submit/v1 account_id, sleeve, market_id, seen_version, p: u32[n] summing to exactly 65536 (the §6.4 Q0.16 contract; n = the target market family's outcome-class count, 1..=8 — the gateway refuses any other width for the market, §5 code 11, so a recorded mismatch is corrupted history) — an external quoting sleeve's recorded forecast, priced through the full quote fold (steps 1–8) at its merge position, tagged with the state version its stamp names; seen_version is the game-state version the client derived it from, recorded intent only, never the batch tag (architecture.md external drive rule 1). The gateway also refuses a SCOPED sleeve's record on another family's market (gateway-protocol §5 code 17), so the runtime halts on such a recorded record (RuntimeError::QuotingScope, architecture.md external drive rule 3) — the same corrupted-history posture as the width mismatch; the record's fields are unchanged
ladder_submit/v1 account_id, sleeve, market_id, seen_version, levels[]: {side, price_ticks, size} — an external sleeve's explicit whole-ladder replace, entering the quote fold at steps 8+ (empty levels = the pull; structural validity is gateway-enforced pre-append, gateway-protocol §5 code 12). A SCOPED sleeve's ladder on another family's market is refused the same way (gateway-protocol §5 code 17), so a recorded one halts (RuntimeError::QuotingScope, architecture.md external drive rule 3) — the record's fields are unchanged
subaccount_opened/v1 {0 operator, 1 customer_key, 2 opening_balance} — a customer SUBACCOUNT an operator session opens (gateway-protocol §2 row 15): operator the session's bound account (an account_created offset — an operator is an account, the role recorded nowhere else), customer_key the customer's Ed25519 public key (RFC 8032; exactly 32 bytes, any other length refused at decode), opening_balance mills — a deposit into the subaccount alone, nothing drawn from the operator's account or any other. The SUBACCOUNT ID is this record's order-log offset (core law 5) — one id space with account_created's offsets, so a subaccount is an account wherever a record names one (order_rejected and order_unfilled's account_id, fill/v1's taker or maker account, the payout/v1 and void_refund/v1 owner, accounts ascending). The key is validated in the folds, never at the wire: a key that is not the canonical encoding of a curve point, is of small order, or is not torsion-free (a point with a small-order component — the strict parse), opens nothing — instruction_rejected{bad_key} (§6.7), no balance credited by the exchange fold or the ledger, and a later instruction naming the offset is foreign_account (matching-engine §5)
instruction_relayed/v1 {0 operator, 1 instruction, 2 signature} — a signed customer instruction an operator session relays (gateway-protocol §2 row 16): operator the session's bound account, recorded by the gateway (the attribution matching-engine §5's operator check reads); instruction the customer's bytes VERBATIM (§6.6.1), 1..=MAX_INSTRUCTION_BYTES long (gateway-protocol §7; any other length refused at decode); signature the Ed25519 signature VERBATIM (exactly 64 bytes, any other length refused at decode). Neither the bytes nor the signature is interpreted at the wire: the exchange fold decodes the instruction, checks the operator, verifies the signature under the subaccount's recorded key and checks the nonce, and records every refusal (§6.7; matching-engine §5). The record's order-log offset is the ORDER ID of a place instruction (core law 5) — the id retail_rested, retail_cancelled and a cancel's target_order name

(account_created lives here because accounts are user-facing history; the complement transform is a pure rewrite downstream, never stored.)

The order log is the client-ingress root log (what account_created's residence already implied): every gateway-recorded client action — orders, account creation, forecasts, ladders, subaccount openings, relayed instructions — rides this one log under one stamp counter (gateway-protocol §4), so a live session still produces exactly two root artifacts (gateway-protocol §8). First-claim sleeve binding: the first recorded forecast or ladder naming an external sleeve binds that sleeve to its account_id for the run — the binding is derived from the log, never allocated (core law 5). The gateway refuses to append a contradicting record (gateway-protocol §5 codes 9/10); a recorded contradiction is corrupted history and halts the run (the codes-4/7 enforcement-point posture).

6.6.1 The signed customer instruction (the bytes instruction_relayed/v1 carries) #

A customer instruction is ONE canonical CBOR map under §3 (integer keys ascending, shortest-form integers, definite lengths, no tags, no floats), CLOSED per kind: a place carries exactly the keys marked for kind 0, a cancel exactly those marked for kind 1, and nothing else.

key field kind 0 place kind 1 cancel domain
0 kind ✓ ✓ 0 place / 1 cancel
1 run_id ✓ ✓ bytes, exactly 32 — the run's id (the run manifest's hash); another run's id refuses, so signed bytes never execute in a second run
2 subaccount ✓ ✓ u64 — the subaccount id (a subaccount_opened offset, §6.6)
3 nonce ✓ ✓ u64 — above the subaccount's last consumed nonce (matching-engine §5 step 4)
4 market_id ✓ — bytes, exactly 32
5 side ✓ — 0 higher / 1 lower
6 action ✓ — 0 buy / 1 sell
7 size ✓ — claim units, 1..=the run manifest's max_order_size (a u32, matching-engine §1)
8 limit_price ✓ — mills, 1..=999, the price of the NAMED side (before the complement transform, matching-engine §3)
9 tif ✓ — 0 IOC / 1 GTC (rests until filled, cancelled, or drained at the close or void; a place stamped in a feed stall never rests, risk-engine.md §3 R7 v2 (e))
10 quoted_version ✓ — u64 — the book version the customer saw (signed, so the stale check is the customer's)
11 target_order — ✓ u64 — the order id (an instruction_relayed offset, §6.6) of the resting order to cancel

Strict, total decode. The bytes are an instruction iff they decode as exactly one kind's closed map, every field in its domain, the run_id this run's, AND re-encode to the identical bytes; anything else is instruction_rejected{bad_instruction} (§6.7), a recorded fate, never a halt. The signature is RFC 8032 Ed25519 (pure, no prehash) over the instruction bytes exactly as recorded, checked by the strict verification (verify_strict: a non-canonical scalar or a small-order component refused) under the customer_key of the subaccount the map names. Sizes. The widest well-formed instruction is a place with every integer at its widest: the map head and eleven one-byte keys (12), two 32-byte strings (2 × 34), three u64 (3 × 9), a u32 size (5), a 999 limit (3) and the four one-byte kind, side, action and tif (4) — 119 bytes; a cancel is at most 68. MAX_INSTRUCTION_BYTES (gateway-protocol §7) sits above both.

6.7 Market-event stream (derived by Matching Engine) #

Event Body fields
quote_accepted/v1 sleeve_id, market_id, state_version, levels resting (POST-shrink — what actually rests after the matching-engine §4 step-3 capacity truncation)
quote_rejected/v1 sleeve_id, market_id, reason: capacity / invalid / market_closed / stale_version — invalid is the §4 step-1 structural refusal (self-cross, duplicate level); market_closed is the §8 non-open-market row; stale_version is a batch tagged below the book version, which must never rest (matching-engine §4 step-1 note — "no path from an old version to a fill"; the original capacity-only list omitted outcomes §4/§8 already required); exposure — reserved, the R4 block's refusal (risk-engine.md §3); stop_loss — reserved, the R9 block's refusal of a stopped sleeve's batch (risk-engine.md §3)
quote_expired/v1 sleeve_id, market_id, superseded_by_version — emitted once per affected sleeve per version advance (a sleeve's stale quote set dies as one fact)
quote_cancelled/v1 sleeve_id, market_id
fill/v1 market_id, taker_account XOR taker_sleeve, taker_is_buy, maker_sleeve XOR maker_account, price_ticks, size, state_version — exactly one maker identity: a sleeve for a resting quote, an account (field 8, maker_account) for a resting retail order of a subaccount (matching-engine §3–§5; a pre-release amendment per rule 1 — absence of field 8 keeps the sleeve-maker semantics, so every recorded fill keeps its bytes); exactly one taker identity: an account for retail fills (§5 — a flat account's IOC order or a subaccount's instruction), a sleeve for bookmaker-vs-bookmaker uncross fills (§4 step 5, which have no account anywhere — an account-only taker could not express an uncross; a pre-release amendment per rule 1). taker_is_buy is the taker's post-complement direction (a pre-release amendment per rule 1): without it a downstream fold cannot reconstruct buyer from seller, and the state-layers §7 audit story — "the ledger at any offset recomputable by anyone with the logs" — fails by construction
order_rejected/v1 account_id, market_id, reason: stale_version / insufficient_funds / market_closed / market_halted / sold_out — market_closed and market_halted are the §8 close-race and halt rows (both terminal states of the state-layers §6 order machine); sold_out is reserved, unreachable: it belongs to the thesis's new-issuance-vs-funded-capacity distinction (the alignment note's phase 1's risk engine); under flat netting, depth exhaustion yields order_unfilled and capacity exhaustion rejects the quote, never the order; self_cross (4), insufficient_position (5) and resting_cap (6) are a subaccount instruction's refusals (matching-engine §5 steps 7–8): a place instruction that would cross a resting order of a subaccount holding the same customer key, an instruction breaking the held-units guard — a sell, or a buy while a sell of the other side rests, beyond the units held (matching-engine §6) — and a GTC place whose remainder would rest while its subaccount already holds the run's max_resting_orders resting orders (model-plugin-layer §12 MF-17) — a pre-release amendment per rule 1, every recorded record keeping its bytes; sold_out takes no discriminant while it stays reserved. On a subaccount's instruction account_id is the subaccount id and the four earlier reasons keep their meaning
order_unfilled/v1 account_id, market_id (IOC found no depth, or a place stamped in a feed stall, risk-engine.md §3 R7 v2 (e))
instruction_rejected/v1 {0 offset, 1 reason} — a refusal matching-engine §5 records before a place instruction reaches its market, or of a cancel: offset the order-log offset of the refused record (an instruction_relayed; for bad_key a subaccount_opened); reason (u64): 0 bad_instruction — the bytes fail §6.6.1's strict decode; 1 foreign_account — the named subaccount is not an open subaccount whose record names the relaying operator (the subaccount untouched); 2 bad_signature — the signature does not verify under the subaccount's key (unsigned, wrongly keyed, or any byte altered); 3 stale_nonce — the nonce is not above the subaccount's last consumed nonce (a replay); 4 bad_key — the opening's customer_key fails the strict parse (non-canonical, of small order, or not torsion-free, §6.6), so no subaccount opened; 5 no_resting_order — a cancel whose target_order is not a resting order of the subaccount (filled out, cancelled, drained, another subaccount's, or never an order); any other value refused at decode. No market id: the record names what the fold could trust
retail_rested/v1 {0 order, 1 account_id, 2 market_id, 3 is_bid, 4 price, 5 remaining} — the remainder of a GTC place instruction resting in the book (matching-engine §3, §5 step 9): order its order id, account_id the subaccount, is_bid the book side after the complement transform, price the book's Higher-unit price (the signed limit after the complement), remaining the units resting; its reservation is taken at this record (matching-engine §6)
retail_cancelled/v1 {0 order, 1 account_id, 2 market_id, 3 remaining, 4 reason} — a resting retail order leaving the book unfilled: remaining the units cancelled, whose reservation releases exactly; reason (u64): 0 customer — a verified cancel instruction; 1 close / 2 void — the drain at market_closed / market_voided (matching-engine §8: every sleeve's quote_cancelled ascending, then the retail cancels ascending by order id); 3 feed_stall — the feed gate's cancel at a stall record's tick, after the pulls, ascending by order id, a halted market's at its market_resumed (risk-engine.md §3 R7 v2 (e)); any other value refused at decode; the filled units stay with the subaccount

6.8 Settlement stream (derived by Clearing House) #

Event Body fields
payout/v1 market_id, owner: account XOR sleeve (the fill/v1 arity — makers are sleeves and hold positions, so settlement pays owners in both id spaces; the original account_id-only row predates the matching-engine §6 pot model), units, price_per_unit_mills on the settlement grid 0..=1000 — the owner's side of the settlement price computed from the claim-series terms (net-long owners at the settlement price, net-short at its complement; CS2 kills family: k/5; match-winner family: 1000 if the claim's side won, else 0 — settlement-contract SC-7), amount = units × price_per_unit_mills (recorded redundantly; decode re-checks). One payout per party with nonzero net at settlement, accounts ascending then sleeves ascending; an epoch's markets settle in MarketId order (ascending derived-id bytes — the settlement stream is byte-gated, so the cross-market order is normative too); Σ amounts over a market drains its pot to exactly zero (§6 pot invariant)
void_refund/v1 market_id, owner: account XOR sleeve (the payout/v1 arity), amount (mills, signed; the SDK spells it amount_mills as it does for payouts) — the owner's posted at the void (its cumulative at-risk cash over its own fills, payments − receipts) returned from the pot; negative = a realized profit on closes paid back. One per party with nonzero posted, accounts ascending then sleeves ascending, markets in MarketId order; Σ over a market = its pot (matching-engine §6 identity), so the rows drain it to exactly zero — settlement-contract SC-V. Minted 1_000_029 with its producer, the clearing engine's unwind . Consumed by the exchange (matching-engine §6 feedback: staged like payouts, applied atomically at void_complete), the bookmaker's own sleeve fold (the same staging), the ledger taps, and the app bridge (refund events, the player's refund_mills)
reversal/v1 reverses_offset (a fill or payout), amount — unminted (rule 4: an id mints with its first producer or declared consumer; the producer is the SETTLED-then-voided path — a round restored after its payouts — out of scope, live operation; the pre-settlement unwind needs no per-fill reversal because a party's posted is exactly the sum of its tranche cash, so void_refund/v1 returns the same money in one row per party)
settlement_complete/v1 epoch — the title-defined settlement grouping (CS2 epoch = round_no; Dota2 epochs = claim windows; the barrier and mint/redeem checkpoints key on epochs, never on a title's round concept); family (SC_FAMILY = 1, text, OPTIONAL — on a board): epochs are FAMILY-LOCAL (every line family's identity rule is match ordinal × line count + line index within ITS family), so on a BOARD — several families on one match — the barrier names the claim family whose epoch settled (ClaimSeries::family, e.g. lol/race_to_kills — a client half holds this text to the wire's MAX_FAMILY_TEXT, gateway-protocol §7; the recorded half applies no decode cap — its only bound is the manifest its producer drew it from) and the consumers that key on the epoch — the exchange engine (settle_epoch), the bookmaker fold (on_settlement_complete), both through the ONE predicate SettlementComplete::covers, and the lifecycle fold (on_settlement, which advances only on its own family's barrier — LifecycleFold::family, learned from its first market) — settle the markets whose (series.family, epoch) match; the SDK app bridge routes by family too (SC_FAMILY read, a bare barrier matching by epoch alone; /health lists the families the run has created markets for so far); ABSENT on a single-family run (an additive OPTIONAL field — an in-place amendment per §7 rule 1, absence = the pre-amendment semantics, so every recorded stream keeps its bytes), and a barrier without a family matches by epoch alone, the rule every consumer had. Emitted by the clearing engine's column (ClearingEngine::board — one column per family, the columns in the board's canonical order, ascending by family text, so the settlement stream's cross-family record order follows the manifest's wire-name order (a claim family is title-prefixed and the manifest's text bare; one run serves one title, so the prefix is uniform and the two orders coincide) — names it; new — the single untagged column — does not)
void_complete/v1 epoch, market_ids[], mode, family (VC_FAMILY = 3, text, OPTIONAL — the board: the family whose epoch the void closes, recorded because epochs are family-local; the exchange and the bookmaker retire the NAMED market_ids — ids are family-exact, no family match needed — and the one epoch-keyed consumer, the lifecycle fold, advances only on its own family's void barrier); mode: unwind (the Flow 5 path: every TRADED market's void_refund rows precede it — a market nobody traded yields none — sound only while claims are house-vs-taker, so it is the live-path mode until claims transfer; exercised by a cs2 round_restored before settlement and by the intra-match line families' ties, unreached lines and unreached minutes — SC-8 / SC-9 / SC-10 / SC-11 / SC-12; SC-V) / void_value (once claims transfer between holders who paid different prices — the growth path's D5, the alignment note's phase 1)

There is no collateral_released event: under the §6 tranche/pot model there is no separate locked bucket to release at settlement — fills move cash into the pot, payouts move it back out, and position/posted clearing rides settlement_complete. The real-world alignment phase's Reserved/Drawn refinement may introduce an explicit release event, minted with its producer.

6.9 Lifecycle stream (derived by Lifecycle Manager) #

On a BOARD the ONE lifecycle stream is written by one writer (the scheduler) from one fold PER COLUMN (state-layers §3): per driving state and per barrier, every column folds in the board's canonical order (ascending family text) and its records are appended before the next column folds, so the stream's cross-family record order follows the manifest's wire-name order exactly as the settlement stream's does (§6.8) — a byte order the merge quadruple's offset tiebreak and the hash chain both read, normative.

Event Body fields
market_created/v1 market_id, epoch, claim_series {title, family (e.g. cs2/kills_over_5 — a client half holds the title to the manifest's MAX_TITLE_NAME_LEN and the family text to the wire's MAX_FAMILY_TEXT, gateway-protocol §7; the recorded half applies no decode cap), descriptor (title-defined — CS2: round_no + team), payout_rule ref, void_value} — generic clearing settles via these terms and NEVER names a title's payout function (the settlement_price move). Match-winner family (LoL + Dota2 + synth): epoch = the match's discovery ordinal in the game-state stream (0 for the run's first match — positional, never allocated), one market per match, family <title>/match_winner, descriptor {0: match id bytes as the title recorded them (32-byte derived id; the betty archive's u64 as 8 big-endian bytes), 1: side wire — always the model's fixed perspective side, 0 = blue / radiant}, payout_rule <title>/match_winner/v1 → ingest::match_winner::settlement_price (1000 mills to the claim's side if it won, 0 otherwise; the other side is the SHORT side of the same book, §6.8). Created on the forwarded match envelope — match_discovered (LoL; the GRID and deaths-block Dota2 logs) or, on the betty FRAME log, dota2/roster_side/v1, the frame block's only pre-outcome match-scoped record carrying the recorded match id (§6.11 rule 11) (a match has no freeze-time, so the market is Pending only for the settlement barrier — it opens as soon as the prior epoch's settlement_complete is folded, vacuously for the run's first match; state-layers §3), closed on the forwarded match_complete (settlement-contract SC-7); no void path. The synth title (§6.18) settles the same family as synth/match_winner: descriptor {0: the 32-byte derived match id, 1: side wire 0 = Team A}, payout_rule synth/match_winner/v1 → the same settlement_price, created on the forwarded synth_discovered, closed on the forwarded synth_complete (team_a_win); no void path. Race-to-kills family (LoL and Dota2 through the one RaceTitle seam): for each line N of the run manifest's race_lines, one market on the perspective side reaching N cumulative kills FIRST — family <title>/race_to_kills, descriptor {0: match id bytes (the same two spellings as the match winner's), 1: side wire (0 = blue / radiant), 2: threshold N}, payout_rule <title>/race_to_kills/v1 → the SAME binary grid (ingest::match_winner::settlement_price), epoch = match ordinal × line count + line index (sequential lines as consecutive epochs, state-layers §3), all created Pending on the forwarded match envelope (the roster on the betty frame log) and opened one at a time by the barrier; closed on the first per-frame snapshot — lol/state/v2 (§6.15.3) / a dota2/player_frame/v1 whose per-slot kills are summed through the bound roster (§6.11.1) — whose side count reaches N (the higher count wins a same-frame double cross), VOIDED — market_voided.reason "tie at K-K on frame F" / "match completed before the line" — on an equal-count crossing or at the completion with the line unreached, through SC-V's unwind (settlement-contract SC-8). Kills-at-minute family (both titles through the same frame-kills seam): for each line (M, N) of the run manifest's minute_lines, one market on the match's TOTAL kills at its M-th per-minute snapshot being OVER N — family <title>/kills_at_minute, descriptor {0: match id bytes, 1: side wire (0 = OVER, the side every market is written on; 1 = UNDER), 2: the kills line N (the race's threshold slot), 3: the minute M}, payout_rule <title>/kills_at_minute/v1 → the SAME binary grid, epoch = match ordinal × line count + line index (sequential minutes as consecutive epochs), all created Pending on the forwarded match envelope and opened one at a time by the barrier; closed on the snapshot whose ordinal within the match IS M (the frame that is the minute — state/v2.frame_index M on LoL, the M-th sampled frame on Dota2) with OVER on a total of N + 1 or more and UNDER otherwise (no push, no tie — settlement-contract SC-9), VOIDED — market_voided.reason "match completed before minute M" — at the completion with the minute unreached, through SC-V's unwind. Side-kills-at-minute family (both titles, the same seam and the same fold): for each line (M, N, s) of the run manifest's side_minute_lines, one market on SIDE s's OWN cumulative kills at the M-th snapshot being OVER N — family <title>/side_kills_at_minute, descriptor {0: match id bytes, 1: side wire (0 = OVER, 1 = UNDER), 2: the kills line N, 3: the minute M, 4: the TEAM s (0 = blue / radiant, 1 = red / dire — replay::side_kills_at_minute::D_TEAM)}, payout_rule <title>/side_kills_at_minute/v1 → the SAME binary grid, epoch, creation, barrier, close and void as the kills-at-minute family (settlement-contract SC-10: the team's count decides, the total and the other team are irrelevant). Gold-lead-at-minute family (LoL and Dota2 through the frame seam's GOLD read): for each line (M, N, s) of the run manifest's gold_lead_lines, one market on side s LEADING BY MORE THAN N gold at the match's M-th snapshot — family <title>/gold_lead_at_minute, descriptor {0: match id bytes, 1: side wire (0 = OVER, the claim; 1 = UNDER), 2: the gold line N (the kills families' threshold slot — a gold amount, 0..=100000), 3: the minute M, 4: the TEAM s (the side family's slot)}, payout_rule <title>/gold_lead_at_minute/v1 → the same binary grid (settlement-contract SC-11: OVER on a lead of N + 1 or more, the lead saturating at 0); epoch, creation, barrier, close and void as the side family's. Tally-at-minute families (towers on LoL and Dota2 through the frame seam's TALLY read, dragons and barons on LoL): the side family's descriptor with a COUNT in slot 2 (0..=20) — the KIND is the claim family (<title>/towers_at_minute, lol/dragons_at_minute, lol/barons_at_minute; never a descriptor field, so a towers and a dragons market over the same numbers differ by family), payout_rule <title>/<kind>_at_minute/v1 → the same binary grid (settlement-contract SC-12: OVER on a count of N + 1 or more — the title's frame reader's tally, §6.15.1 / §6.11.1); epoch, creation, barrier, close and void as the side family's. House-line families (both titles through the frame seam's KILLS read — the first families whose line the operator does not pin): ONE market per match per family, epoch = the match's discovery ordinal (the match winner's spelling); family <title>/total_kills — descriptor {0: match id bytes, 1: side wire (0 = OVER, the claim; 1 = UNDER), 2: the line N} — or <title>/kill_handicap — the same plus {4: the TEAM s whose kill LEAD the line reads}; payout_rule <family>/v1 → the same binary grid (settlement-contract SC-13: OVER on a final total of N + 1 or more / a final lead of N + 1 or more, the lead saturating at 0). Created NOT on the envelope but on the match's OPENING snapshot — the run manifest's house_line open_minute-th by ordinal — with the line the HOUSE sets there from the manifest's candidate range [lo, hi] by the run's house model (the setter: the candidate whose OVER probability is nearest one half, ties to the lower line then the lower team — SC-13 rule 5; a pure function of the snapshot, the fold's state, the recorded artifact and the recorded terms, so the descriptor bytes are a fold of the log, core law 5); the barrier — the prior match's settlement — is already folded by then, so the market opens in the same lifecycle step; closed on the forwarded match_complete, settled on the LAST snapshot before it (SC-13 rule 3). A match completing before its opening snapshot has no market at all — nothing created, nothing voided; the family has no void producer.
market_open/v1 market_closed/v1 market_halted/v1 market_resumed/v1 market_voided/v1 market_id (+ reason on halt/void)

void_value is present from v1 per growth-path D5 — demo markets set it but never exercise it (preflight excludes restored rounds; a voided race line does not use it either — SC-V returns each party's posted).

6.10 Control stream (root) #

Event Body fields
model_registered/v1 model_id, format_id, feature_schema_id, meta_hash, artifact_size
model_gate_result/v1 model_id, gate: static / determinism / parity / backtest, pass, details_hash
model_shadowed/v1 model_promoted/v1 model_suspended/v1 model_id (+ bookmaker on promote, reason on suspend), effective: next_market_open / immediate
model_output_invalid/v1 model_id, state_version, reason: panic / bad_sum / empty_support
sleeve_suspended/v1 sleeve_resumed/v1 {0 sleeve (u32), 1 reason (u64), 2 effective (u64: 0 immediate, the only value)} on suspend, {0 sleeve (u32)} on resume — the reason domain the sleeve record's own (SleeveReason, not the model record's): 0 operator, the operator's pause; 1 RETIRED before any producer (risk-engine.md §12 C78: reserved and unreachable in every epoch before the first whose rule chain declares R7 (v1, or v2 since G11), refused from it — the feed pause is the run's match-log record, risk-engine.md §3 R7); 2 liquidated, the cross-run stop (risk-engine.md §4 R6 v1, §8 E7; C104), never lifted by a resume (C105); any other value refused at decode — 1_000_066–1_000_067, minted at gate G5 with their live writer, the operator gateway, appending them from the frames sleeve_suspend/v1 and sleeve_resume/v1 (gateway-protocol §2 rows 12 and 13); a sleeve-level record whose owner is the pricing crate's control events module beside model_suspended, read by the runtime's control consumer's sleeve fold beside the model records, never a model-registry action (risk-engine.md §5, §11)

Writer split. One writer per stream (dev-kickoff §1) forbids the operator edge and the wrapper sharing one log: the operator-authored lifecycle events above ride the control ROOT log (fsync-before-ack — its writer is the SDK/operator gateway edge; the recorded-control CONSUMPTION is below), while the wrapper's model_output_invalid/v1 emissions ride a DERIVED per-run model_health stream (the runtime's tick loop is its one writer; §5's derived row applies — it is a recomputable fold output, not an impure edge). Same event vocabulary, two homes by writer.

Control-root consumption: the runtime folds a recorded control root as a third run input — records ride merge class 3 at their recorded stamps, the sleeve records partitioned out to the runtime's sleeve fold BEFORE the registry fold (risk-engine.md §5; model-plugin-layer.md §11), the model records into the model-plugin-layer.md §9 registry fold (never the engine), with the strict tick-join and effect-timing mechanics pinned in model-plugin-layer.md §11. The live writer: operator frames (gateway-protocol §2 rows 7–13) append fsync-before-ack at the gateway edge, pre-validated by the drain-time mirrors — the mirror registry for the model frames (rows 7–11), the sleeve mirror over the manifest's full declared bookmaker-sleeve set for the sleeve frames (rows 12–13) — so a record the fold would refuse is never appended; a run citing an allocation under a document whose risk chain names the desk-period chain (R5–R6b) is served with a control root (ControlRootRequired, checked in Scheduler::new_included, which every serve path calls, risk-engine.md §5), every other allocation-citing run keeping its lineage and ids; under the start-time derivation (risk-engine.md §8 E7: the desk fold over the epoch-log prefix and the sealed streams of the runs it names, its inputs the epoch store's log and the Verified runs' sealed streams) the gateway appends a reason-2 sleeve_suspended as the run's first control record for a liquidated pool and, fail closed, under a document whose risk chain names R6 v1, a run served from a store whose epoch-log prefix carries a run_verdict{Verified} row the store holds no sealed stream for is refused at start; served when it carries none; a document naming no R6 v1 derives nothing, the store's run-stream member landing with the live verdict writer; a reason-2 record is admitted only on a run whose inclusion's document names R6 v1; elsewhere SleeveReasonUndeclared, code 14 at the wire (risk-engine.md §12 C105); gate details documents live content-addressed beside the log (model-plugin §10 gate-wiring note).

6.11 Dota2 match log (root — the title-agnosticism slice) #

Why this stream exists: a pre-registered test of the title-agnostic thesis. Real professional Dota 2 matches (the betty-dota2 archive — an operational source like any demo provider, normalized at the edge per §1) are ingested into canonical segments through the unchanged stream-core substrate. Pass condition: zero diff in stream-core; any universal-layer change the slice forces falsifies the thesis and is recorded as a finding, not patched around. Dota2 models remain a growth-path slice (architecture.md roster); the match-winner market/lifecycle/settlement is §6.14.1's forwarding set, §6.9's market_created row and settlement-contract SC-7, and the runtime serves the betty FRAME log (rule 11, §6.11.1 — the dota2 run-manifest title binds the frame projector, the match-winner policy / router / outcome source, and the dota2/v1/v2 extractor; model-plugin-layer §12). This section defines the match-log root stream, the frame log's serve order, its game-state projection, and one demonstration predicate.

Schema ids come from the dota2 block, 1000..=1999, names prefixed dota2/ (§7 rule 4). As with §6.1, the normative body field ids are the constants in crates/ingest/src/dota2/ while the pinned registry file (§7 rule 5) has no producer — code is the id registry of record. As §6.1's, this match log may carry the three universal sys/ feed-health records (§6.13 rule 12 (d); risk-engine.md §3 R7).

Event Body fields
match_discovered/v1 match_id (the recorded source match id — recorded, never allocated), source_ref (BLAKE3 over exactly: u64-LE(len(metadata table bytes)) ‖ metadata table bytes ‖ u64-LE(len(state table bytes)) ‖ state table bytes — a length prefix per component, metadata table first), roster[]: exactly 10 {slot, hero} pairs sorted by slot
hero_death/v1 slot — the player slot whose scoreboard death count incremented; replay_ts is the engine tick of the sample where the increment was observed
match_complete/v1 match_id, radiant_win, scoreboard_deaths (the source metadata's total death count — a diagnostic cross-check per the SC-1 pattern: recomputed truth is the hero_death stream)
player_frame/v1 (schema id 1009) tick (raw engine tick; consecutive one-second samples of one player step by ≈ 30, measured against real data with occasional off-step rows — rule 4), players[10]: {hp, max_hp, mana_x100, max_mana_x100 (fixed-point ×100, quantized at the ingest edge — rule 6), gold, net_worth, xp, is_alive, kills, deaths, assists, last_hits, denies, level, strength_x100/agility_x100/intelligence_x100/armor_x100/move_speed — all five Option, a real per-slot-per-match all-or-nothing gap in the source (rule 6)}, raw archive slot order 0..=9 — carries NO team semantics (rule 3 still holds for this record)
structure_destroyed/v1 (REUSES the GRID-block id 1006, does not mint a new one) {side, kind, lane, tier, destroyer: Option<(slot,Side)>} — identical shape to §6.14's own structure_destroyed/v1; betty-derived rows always carry destroyer: None (rule 7)
roster_side/v1 (schema id 1010) match_id, sides[10]: {Side} indexed by raw archive tick-slot (0..=9) — one record per match (a match's side assignment is fixed for its whole duration, not per-tick), resolving the team semantics player_frame/v1 deliberately does not carry (rule 9)

Normative derivation rules (shard-0 preflight):

  1. Roster resolution. The per-second state table maps slot ↔ hero name. A match is eligible iff its slots are exactly {0..9}, every slot carries exactly one non-empty hero name, no name is shared between slots, and every per-slot deaths counter is non-decreasing in tick. Structurally malformed source data likewise rejects: conflicting samples at one tick, cross-match row leakage, a state table with no samples, a match with no metadata row, and a death total beyond the plausibility cap of 10,000 (a data-derived counter never sizes record emission unbounded — §4's parse validation). An ineligible match is rejected at the edge with a typed reason surfaced to the operational caller — never guessed at (3 of the first 10 archive matches fail this: empty hero names in the source parse); durable recording of rejections belongs to the ingest run report, which has no producer.

  2. Death occurrence is the per-slot deaths-counter increment, not the combat-log death event. Audit result: counter increments equal the scoreboard total in 7 of 7 eligible preflight matches, while combat-log DEATH rows overcount (illusion deaths, no illusion flag in the source) and tick-joins against the counter are lossy in both directions. A counter jump of k at one sample emits k hero_death events at that tick. Each slot's counter starts from an implicit 0 before its first sample — a non-zero counter at the first observed sample emits that many events at that sample's tick, so the emitted total always equals the sum of the final counters.

  3. No killer or team attribution. The source cannot support it settlement-grade (the state table's slot ordering is not team-ordered; ward events cover only warding players). Attribution fields belong to a feed that carries them (out of scope here); omitting them beats recording a guess.

  4. Clock and ordering. replay_ts = raw engine tick (u64; the source samples at one-second cadence — ≈ 30 engine ticks per sample, so 30 replay-ts units are one game second of THIS archive: consecutive rows of one player in the source ticks table step by 30, with occasional off-step rows — the step this archive's one-second sampling produces, confirmed against real data as the player_frame/v1 row above records, not an engine constant; the EMITTED player_frame/v1 cadence is rule 6's every-60th-distinct-tick downsample, ≈ 1,800 units apart. The GRID-fed Dota2 log stamps the envelope sequenceNumber instead, §6.14 rule 6, and carries no wall-time unit). Within one tick, hero_death events order by slot ascending, then per-slot ordinal; match_discovered precedes and match_complete follows all hero_death events sharing its tick. seq is the 0-based running record index of the match stream — strictly increasing within equal replay_ts, a valid §4 tiebreak, pinned here because two conforming encoders must produce identical bytes. match_discovered carries the first observed sample tick, match_complete the last. The archive is post-hoc and final, so the slice has no watermark/revision events — those are live-path vocabulary.

  5. Floats never cross the edge. The source's float columns (game_time, positions, mana) are simply not read; the slice consumes integer, boolean, and text columns only.

  6. Enrichment: player_frame/v1. The archive's ticks table samples once per second per player — raw ingestion would dwarf every other title's record volume for no feature-layer benefit, so the edge keeps a row-stride downsample of every 60th distinct tick (ascending order, not a fixed tick-modulus — robust to whatever tick offset a match's first sample falls on; dota2-edge::normalize_frame:: FRAME_CADENCE_STRIDE), roughly one snapshot per in-game minute, mirroring LoL/CS2's own per-minute/per-tick sampling convention. Betty's kills/deaths/assists/last_hits/denies are already CUMULATIVE counters embedded per tick (unlike LoL's event-only Riot timeline), so one enrichment schema suffices — no sibling cumulative-fold schema is needed. A frame is STAMPED at its tick (replay_ts = the body tick, the edge's to_record), and both decoders — the full one and the frame-kills seam's per-slot reads (replay::frame_kills — kills for the race and the two kills-minute families net_worth for the gold-lead family) — refuse a record whose body tick disagrees with its stamp (TickMismatch; the shared frame fold orders and reports on the body tick, the runtime stamps on the envelope). The counters are unbounded u64s with no cap at the edge: a consumer that sums them checks the sum (the frame-kills seam's reads halt typed on overflow, KillsOverflow / NetWorthOverflow), never wraps or clamps. Float columns (str/agi/int_/armor/mana/max_mana) are quantized to ×100 fixed-point at the ingest edge (rule 5 still holds past the edge); five attribute/movement fields are Option — a confirmed, per-slot-per-match all-or-nothing gap in the source archive (~8-45% of rows depending on field), never a defaulted zero. Position (x/y — ~0.2% of rows carry an unexplained garbage value) and items (text names, no canonical id table yet) are deliberately excluded, real scoping decisions.

  7. Enrichment: structure destructions reuse structure_destroyed/v1 (id 1006). A tracked building entity's alive→dead transition (§6.14's own team code 2/3 → Radiant/Dire, "top"/"mid"/"bot" → Lane, tier 1..=4) emits the SAME struct/schema §6.14 already defines, populated from this different source — not a new id. The cited precedent for reuse (§6.12 OpenCS2 vs §6.13 GRID CS2, both deriving into the shared §6.1 vocabulary) is weaker than it first looks and the gap is recorded rather than papered over: the betty stream is not wired into any GRID consumer — §6.14.1's Dota2Projector (the dota2/state/v1 fold) reads only crate::dota2::grid records, and ingest_frame_shard had no driver outside dota2-edge itself. The frame log's envelope: the betty frame log HAS a consumer — replay::dota2::Dota2FrameProjector (§6.11.1), which the dota2 run-manifest title binds; it DECODES and FORWARDS this betty-derived id 1006 verbatim (§6.11.1 — the frame reader's towers tally) and halts on every GRID-only game-state id (hero_death/v2, hero_respawn/v1, state/v1 — the GRID envelope pair rides the shared forwarding set, §6.11.1), and the projector is chosen by the run's title, never by record provenance — so nothing produces a mixed log, and the deferral below stands for the day a real merge is proposed. The betty path is the one producer of destroyer: None under id 1006 — every GRID-fed record carried Some(...), an asymmetry §7 rule 3 permits (the field was already Option) but which a consumer must not read as "GRID never attributes a destruction." Should the two streams ever need to merge into one fold, that fold must be able to tell them apart (Record's envelope carries no provenance field, §4) — deferred until a real merge is proposed, not solved speculatively here. destroyer is always None here — betty has no attribution column, a real absence, never a guess withheld. Grounded against a real shard (every tracked entity): every entity's first recorded sample is alive, no entity ever re-appears alive after a dead sample (the edge refuses a violation as FrameIneligible::EntityResurrected rather than accept it structurally), and the observed destruction count per entity is 0 or 1.

  8. Enrichment: tie-break ordering, pinned for byte-identical re-implementation (rule 4's own standard, extended to the pair). Within one tick, a player_frame record precedes any structure_destroyed records sharing that tick (source order in the edge's emission list, stable-sorted by tick); two simultaneous structure_destroyed records order by their source entity_name ascending (the edge folds buildings into a BTreeMap keyed by entity name before emission). seq is the running record index, as rule 4. Latent in the real archive — a real shard sample (shard 0000, 126 total destructions) has zero same-tick collisions — but pinned here so an independent encoder facing one produces identical bytes, and exercised deliberately by the leg-7 dota2 tally fixture (dev-fixtures::dota2_tally: a tower AT a frame's tick, after it, first counted at the next frame — settlement-contract SC-12 rule 3).

  9. Enrichment: roster_side/v1 — recovering team semantics rule 3 left out, via a cross-table join. Rule 3 holds exactly as stated for the ticks/state table's own slot column: it carries no team convention (the spawn-position cross-check). The metadata table (matches.parquet), however, DOES carry a fixed convention on its OWN per-player index — p-index 0..=4 is always Radiant, 5..=9 always Dire — verified by cross- referencing replay_pb_hero_id/replay_pb_is_radiant (pick/ban attribution) against each match's p{i}_hero_id columns: nearly every match carries usable attribution (the exact count is sensitive to how duplicate/ambiguous pick-ban entries are filtered — two independent recomputations agree within one match), and EVERY match checked, on every filter tried, confirms the 0..=4/5..=9 split with zero exceptions. MatchMeta::roster_kda (dota2-edge:: source) reads each p-index's (kills, deaths, assists) triple; dota2-edge::normalize_roster::resolve_roster_sides joins a tick-slot's FINAL (kills, deaths, assists) against this roster by exact triple match, recovering which p-index (hence which side) that slot belongs to — a join keyed on incidental triple uniqueness (validated, not a guaranteed identity relation), never a guess when it can't resolve. The join makes FIVE AND FIVE structural, and the canonical decoder — the trust boundary for a record read back off a segment — re-derives it: roster_side/v1 with any other split refuses (SidesImbalance; under the race family a lopsided roster would sum up to ten counters into one side). The per-match binding (RosterBinding) tells matches apart by the recorded id, never by the side pattern alone. Refusal is a typed RosterSideIneligible, never guessed at (rule-1 discipline), and fails three genuinely different ways, each with its own frequency AND cause (full-archive recount, dota2-edge/tests/ roster_side_conformance_gate.rs, all 9,385 matches, #[ignore]d — pinned as exact assertions there, so these figures cannot silently drift from the code that produces them): 9,086/9,385 (96.81%) resolve cleanly. Of the 299 refusals, DuplicateRosterKda — a coincidental tie between two real rostered players — is the DOMINANT cause (276, ~92% of refusals); UnmatchedSlot is rare by comparison (22, ~7% of refusals) but, per a separate qualitative inspection of real cases, is itself typically NOT a KDA coincidence — it is a completely inactive raw tick-slot (every field pinned at zero for the whole match, an archive data-quality defect with no roster counterpart); AmbiguousSlotKda — two tick slots whose final triples both match ONE roster entry, so the join is not a bijection and every slot "resolves" while another entry goes unclaimed — occurs once: match 7675230106, the SAME inactive-slot defect UnmatchedSlot names, except the placeholder slots' all-zero triple collides with a real rostered player who genuinely finished 0/0/0 (tick slots 0 and 1 carry an empty hero string and every field at zero for all 900 samples; slot 4 is p8, Tiny, a real 0/0/0; and metadata p0 (5/0/2) and p7 (1/1/4) have NO tick series at all, so the frame stream can observe only 15 of the match's 21 kills). Before the check the join resolved it 4 radiant / 6 dire and materialized it. The edge refuses it at the mapping — and it MUST be refused, never rescued by a side tie-break (net_worth / hero would recover the sides unambiguously here): a match whose frame stream misses two players' kills cannot settle any kills line. Over the whole archive every UnmatchedSlot match and the one AmbiguousSlotKda match carry a fully inactive tick slot, and none of the 9,086 clean matches does — the join refuses 100 % of the archive's inactive-slot defect. Neither SlotSetInvalid nor MissingMetadata occurs anywhere in the archive (0 each) — every match carries all ten slots and a metadata row. player_frame/v1's own shape and "no team semantics" claim are UNCHANGED (§7 rule 1): roster_side/v1 is a new, independent sibling record a consumer joins against by match_id, not a reinterpretation.

  10. The corpus: a SECOND match_complete/v1 derivation site, independent of rule 2's batch pipeline. trainset::dota2_corpus (the Dota2 training-corpus builder, feeding pricing::feature:: dota2's dota2/v1 schema) builds its own match_complete/v1 record directly from MatchMeta.radiant_win/scoreboard_deaths — the SAME schema 1002 wire shape rule 2 already defines, reused unchanged at a NEW site rather than routed through the batch normalize_match. This is deliberate: normalize_match's eligibility gate validates the ticks-table hero-death TIMELINE (slot-set validity, monotonic per-slot death counts, an implausible-total cap) — checks the dota2/v1 feature schema never depends on (it reads player_frame/v1

  • roster_side/v1 only, rule 6/9). Gating the corpus's label on that orthogonal validation would silently drop matches the corpus's own independent checks already accept, for reasons irrelevant to what is being trained — so the label is its own THIRD concern, alongside player_frame's own eligibility and the roster_side join (mirrors FrameMatchOutcome's own "different columns, different failure reasons" split, rule 9). player_frame/v1's and match_complete/v1's own shapes are UNCHANGED (§7 rule 1) — this is a new derivation SITE, never a reinterpretation, the same posture rule 7 already established for reusing an EXISTING schema id (structure_destroyed/v1, betty- derived tower destructions) unchanged at a new site; rule 9 mints a brand-new id (1010) and is not this precedent.
  1. The frame log's SERVE ORDER, and roster_side/v1 as its match envelope. The frame log carries no match_discovered/v1 (that record is the deaths pipeline's, rule 1 — and gating the frame corpus on its hero-name eligibility would drop matches for reasons irrelevant to what is trained, rule 10's own argument). The frame block's match-scoped record carrying the recorded match id — the only pre-outcome one — is roster_side/v1 (rule 9), so it IS the frame log's envelope: trainset::dota2_corpus::build_match_records places it FIRST, at min(the edge's recorded first_tick, the first emitted record's tick) — the edge's stamp is the ticks table's minimum (≤ every sampled frame), but the buildings table is sampled independently and a structure_destroyed/v1 row can precede the first frame, so the bound is what keeps the sealed log's replay_ts non-decreasing at position 1 — then the player_frame/v1 / structure_destroyed/v1 records in tick order (rule 8), then match_complete/v1 LAST at the last frame tick; every record's seq is restamped to its final position (rule 4). The match-winner seam (replay::dota2::Dota2::discovery) recognises roster_side/v1 as a discovery spelling the u64 id as 8 big-endian bytes — the SAME bytes match_discovered/v1 yields — so a frame log and a deaths log of one match derive the same market descriptor, and a log carrying both is idempotent in the tracker (same id re-announced); it reads only the id, the sides are the shared binding's — the extractor's and, under the race family, the race fold's frame reader's (§6.11.1). Segments sealed before the envelope rule carry the roster second-to-last: no stream fold can serve them (the extractor meets a frame before its sides; the market has no envelope before the first quote). An operator re-materializes them — delete the segments directory first, the resume check would otherwise skip them as already sealed — and re-exports. Stamp discontinuity: the corpus builder now stamps state_version = the snapshot's offset in the projected game-state stream (§6.11.1; the LoL builder's rule, §6.15.3 rule 1), so a re-export shifts every row by the leading roster record (+1) — and, by every forwarded structure_destroyed/v1 before the row's frame — and will not reproduce a banked dataset_split_hash — vectors and labels are unchanged, and the spent Dota2 test split stays spent.

DP-1 — demonstration predicate (deliberately not a settlement contract): deaths_over_K(match) := (count of hero_death events) ≥ K — a pure fold over the canonical stream, the team-free mirror of the CS2 kills family. The gate cross-checks the fold against match_complete.scoreboard_deaths for every ingested match.

6.11.1 Dota2 betty game-state stream (the frame log projected by Replay) #

The frame log (rule 11) projects into the game-state stream the dota2/v1/ v2 extractor reads — and, the dota2/race-v1 race extractor and the race fold's frame reader (model-plugin-layer §8) through replay::dota2:: Dota2FrameProjector. There is no cumulative fold to run: the archive's per-tick counters are already cumulative (rule 6), so each player_frame/v1 IS the snapshot and passes through verbatim. Offset of this stream IS state_version (§4).

Event Body fields
player_frame/v1 forwarded verbatim — the snapshot (one per sampled tick, rule 6); offset IS state_version — AFTER a full decode in the projector (the race fold's partial kills read runs in the lifecycle step ahead of the extractor's full decode with durable appends behind it, so the projector — first in the tick — is the one refusal site for a torn frame body). The race family (settlement-contract SC-8) is decided on it: each side's kills are the per-slot cumulative counters summed through the bound roster (rules 3/9), read by the allocation-free ingest::dota2::frame::kills_from_record (the tick and the ten counters, no player block decoded — three folds read it per frame) inside the race fold's frame reader; the raw tick is the frame index a tie's reason text quotes. The kills-at-minute family (SC-9) reads the same per-slot sums through the same shared frame fold and judges their total on the frame whose ORDINAL within the match is the line's minute — the M-th sampled frame at the rule-6 stride The gold-lead family (SC-11) reads the same per-slot frame through the same seam as net_worth, summed per side through the roster (ingest::dota2::frame::net_worth_from_record), and judges the team's LEAD on that same ordinal frame.
forwarded envelope roster_side/v1 (the match envelope, rule 11: the extractor — and, the race fold's frame reader, both driving the ONE ingest::dota2::roster_side::RosterBinding — binds the match's sides at it, halts on a frame before it, and halts on a later roster record whose sides contradict the bound ones — an identical repeat is idempotent; the match-winner lifecycle consumer creates the match's market at it, and a DIFFERENT match announced while one is live is the tracker's halt, settlement-contract SC-7 rule 4, which runs before the extractor in the tick) and match_complete/v1 (closes it, and releases the extractor's side binding for the next match — model-plugin §8) pass through with original bodies and envelopes — the Dota2::FORWARDS set (§6.14.1)
forwarded event structure_destroyed/v1 (— decoded at the projector, forwarded verbatim; the frame reader's towers tally; occupies a state_version offset)

Forwarded: structure_destroyed/v1 passes through verbatim — the tower events the title's frame READER (replay::dota2::Dota2FrameReader: the roster binding AND a running per-side tally, replay::tally::Tally) folds into the towers-taken count the leg-7 the tally families read at each frame (FrameSource<Tally>; a tower is credited to the side that TOOK it — the other side than its recorded owner; the projector DECODES the event before forwarding it, the validation posture of a frame). Same-tick rule (normative, settlement-material): within one tick a player_frame/v1 precedes the structure_destroyed/v1 records sharing its tick (rule 8), so a tower destroyed AT frame T's tick is first counted at frame T + 1 — unlike LoL, where a minute's events precede its frame (§6.15.2) and are counted at that minute's snapshot. Declared and consumed WITHOUT emission: the deaths block's hero_death/v1 — no game-state consumer reads it (the feature grid derives from frames alone, rule 9). The Dota2::FORWARDS union spans both blocks, so a deaths-block envelope (match_discovered/v1) or a GRID envelope (the v2 pair) on this log is forwarded verbatim too: such a log opens and closes its market and emits no snapshot. A forwarded structure event occupies an offset of this stream (state_version, §4): frame stamps shift by the tower events before them versus earlier exports (the §6.15.3 stamp-discontinuity rule, now on this title too — vectors and labels byte-identical, a banked dataset_split_hash not reproducible by a re-export). Any other schema — a GRID hero_death/v2 / hero_respawn/v1 / state/v1, a foreign title — halts (§7 rule 2). The projector checks schema ids only: a frame's body is validated by the extractor in the same tick (a malformed one halts the run there), and the served log's integrity control is the segment's chain hash (§4). The projector is stateless, so two replays of one frame log are byte-identical by construction (the determinism posture of §6.14.1).

6.12 OpenCS2 source derivation (training corpus → the §6.1 vocabulary) #

Why this section exists: the OpenCS2 dataset (blanchon/opencs2_dataset — an operational source like any demo provider, normalized at the edge per §1) supplies the real-data training corpus. Unlike §6.11 it mints no new events and no new schema ids: the adapter (crates/opencs2-edge) emits the existing §6.1 vocabulary through the ingest::cs2 constructors, so everything downstream — settlement, replay, features, markets — consumes it exactly as it consumes a demo parse. This section pins the derivation rules from that source's tables (per-player tick streams + a rounds index + a kills table) to §6.1 events.

Normative derivation rules (match 2391807 de_dust2; each rule verified against the raw rows):

  1. Slots. The source's global player slots 0..=9 (partition numbering ≡ kills-table slots — verified by death-tick alignment) map 1:1 onto canonical slots.
  2. Teams and identity. team_num 2 = T, 3 = CT; side strings "t"/"ct". match_id is derived: BLAKE3("opencs2/match/v1" ‖ u64-LE(source id) ‖ map name). source_ref is BLAKE3 over exactly: u64-LE(len(index bytes)) ‖ index bytes ‖ u64-LE(len(kills bytes)) ‖ kills bytes ‖ for each PRESENT tick table in (round, slot) ascending order: u64-LE(round) ‖ u64-LE(slot) ‖ u64-LE(len(table bytes)) ‖ table bytes — the key prefixes disambiguate which tables are absent (§6.11's len-prefix precision discipline).
  3. Clock. replay_ts = raw demo tick (64/s, verified exactly against the index duration columns). Rounds must not overlap — each round's official_end_tick bounds the next round's round_start_tick; seq is the 0-based running record index (as §6.11 rule 4).
  4. Round window. round_start/v1 at round_start_tick with the roster; round_end/v1 at round_end_tick (the SC-2 cutoff) with finality Canonical (the archive records finished rounds) and the index's t_kills/ct_kills as the reported diagnostics — SC-1 recounts from the kill events. Play continues to official_end_tick (round_end ≤ official_end ≤ next round's start). The archive's reported counts include post-round kills, so post-cutoff rounds legitimately flag the SC-1 reported-mismatch diagnostic (the kill-event stream is truth; the report is a cross-check).
  5. Roster. The slots whose tick stream has at least one row in the round (a present-but-empty stream contributes nothing); each stream's side is its constant team_num (a mid-round team change is malformed).
  6. tick_state cadence. Sampled every sample_every demo ticks (the NormalizeConfig stride; default 16 = 4 Hz — a finer stride raises the cadence, e.g. 6 ≈ 100 ms) from freeze_end_tick while ≤ round_end_tick. A rostered player with no row at a sampled tick (streams stop at death) gets the dead-at-origin padding — hp 0, armor 0, weapon 0, position 0/0/0, alive false — the DEFINED missing-slot value. Positions quantize onto the fixed-point grid (map units × 256) at the edge; the source's −1 held-nothing weapon sentinel maps to canonical 0 (= none). 6b. Enriched derivation (tick_state/v2). When the adapter runs enriched (NormalizeConfig.enriched), each present player row also carries the source's aim (pitch, yaw, delta_pitch, delta_yaw, float degrees) and velocity (velocity_x/y/z, float units/s), each quantized onto its ×256 fixed-point grid at the edge exactly as positions are (floats never persist, §3 rule 1) and refused if non-finite or past its bound (MAX_ABS_AIM_X256 / MAX_ABS_VELOCITY_X256 → UnquantizableMotion); plus the raw source input buttons bitmask, carried verbatim (its bit vocabulary — attack/jump/duck/move — is a feature-layer concern, opaque to the canonical event). A dead player's enriched fields are all zero (no aim, velocity, or input), the enriched half of the dead-at-origin encoding. Only the tick-state records differ between the v1 and enriched configs — every other event (round_start, kill, bomb, round_end) is version- and cadence-independent.
  7. Clock field. clock_ticks is the remaining active timer, HUD-style: 7 360 − elapsed-since-freeze-end before the plant, 2 560 − elapsed-since-plant after, saturating at 0 — always inside the dust2/v1 round_clock bounds.
  8. Bomb. Standing bomb_state per sample: Planted from bomb_plant_tick onward, else NotPlanted. bomb_event/v1 Planted at the plant tick (site a = 0 / b = 1); Defused/Exploded at round_end_tick when the end reason (bomb_defused/bomb_exploded) says so. slot is Null on all three — the source attributes no actor.
  9. Kills. One kill/v1 per kills-table row at its demo tick. Attacker slot −1 (the source's no-player-attacker sentinel) → attacker = Null (credits no one, SC-1). Recorded sides must agree with the roster. The kill window is [round_start_tick, official_end_tick]: kills after the round_end cutoff are SC-2 post-cutoff events, emitted AFTER the round_end record at their real ticks — recorded, tallied by the settlement predicate as post-cutoff diagnostics, never settled (a live feed cannot refuse the post-round, so neither does the batch path). A post-decision plant emits the same way; a bomb end reason with a post-cutoff plant is contradictory and refuses. Beyond official_end is outside every game phase: such a kill refuses the match, never silently drops — every kill row is validated even when its tick falls outside the emission window.
  10. Same-tick order. tick_state, then the plant bomb_event, then kills (source order), then the terminal Defused/Exploded bomb_event, then round_end, then the post-cutoff tail ascending (a post-decision plant precedes same-tick kills, as in-round) — state precedes the events that follow from it, the terminal bomb concludes the round's final kills, the settlement boundary precedes the post-round tail. Malformed source shapes refuse the whole match with a typed reason (§6.11 rule-1 discipline); emission is capped before any data-derived loop (200 rounds, 10 000 kills, 1 000 000 ticks per round window).
  11. Match envelope. match_discovered/v1 then match_live/v1, both at the first round's round_start_tick, preceding every round event; match_complete/v1 (finality Canonical) at the last round's official_end_tick, following every round event — so two conforming encoders produce identical bytes (§6.11 rule 4 discipline).
  12. Cadence downsampling equivalence. A coarser-stride match log has two byte-identical productions: re-materialize from the source tables at stride N (rules 1–11 with sample_every = N), OR resample an already-materialized stride-S log by keeping every (N/S)th tick_state per round (equivalently: dropping the non-sample tick_states — for a stride-1 master this is simply every Nth) and re-sealing at the new cadence (ingest::cs2::resample). Formally, resample(materialize(S), N) equals materialize(N) byte-for-byte whenever S divides N. This follows from rules 6/6b + 10: only tick_state presence depends on the stride (every other event is cadence-independent), the stride-N sample ticks (freeze_end + i·N) are a strict subset of the stride-S ticks, and emission is a stable order by (replay_ts, same-tick class) — so dropping the non-sample tick_states and renumbering seq reproduces the direct materialization exactly. Resampling never invents ticks: a target the source stride does not divide (upsampling, or a non-multiple) is refused. This is what lets the serve box (no corpus, no parquet) materialize any run's cadence from ONE stored finest-cadence master (model-plugin-layer.md §8, "store rich, reduce by parameter").

The archive is post-hoc and final, so the slice has no watermark/revision/round_restored events — those are live-path vocabulary (as §6.11 rule 4).

6.13 GRID CS2 source derivation (Series Events → the §6.1 vocabulary) #

Why this section exists: the GRID partner feed (grid.gg) is a real-data source normalized at the edge per §1. Like §6.12, it mints no new events and no new schema ids — the grid-edge adapter emits the existing §6.1 CS2 vocabulary through the ingest::cs2 constructors, so replay/settlement consume it exactly as they consume a demo parse. This section pins the derivation from GRID's Series Events feed (the actor-action-target JSONL stream) to §6.1. (The partner-specific mapping detail lives in the gitignored GRID CS2 events-mapping note; this section is the normative contract.)

Normative derivation rules (grounded on a live CS2 capture; each rule verified against the real bytes in the grid-edge conformance gate):

  1. One canonical match = one GRID game. match_id is derived: BLAKE3("grid/cs2/match/v1" ‖ game-UUID bytes), the UUID being the game-started-round actor.id. source_ref is BLAKE3("grid/cs2/series-events/v1" ‖ u64-LE(len) ‖ captured bytes) — the §6.12-rule-2 length-prefix discipline. Live-path exception (the live driver, data-conformance finding): this formula hashes a COMPLETE captured file, which a live capture does not have at Cs2LiveDriver::new construction time — bytes accumulate over the dial's real duration, and the driver needs its source_ref before any of them arrive. A live-captured match_discovered/v1's source_ref is instead grid_edge::live_source_ref(series_id, game_id) — BLAKE3("grid/cs2/ live-session/v1" ‖ length-prefixed series_id ‖ length-prefixed game_id), still derived per core law 5, but "which live session produced this" rather than "hash of these exact bytes". Scope: CS2 live capture only, mirrors rules 3/4/11/12's own pattern of naming an explicit live-path carve-out rather than silently diverging from the batch rule.
  2. Map is not in the event feed. GRID Series Events carries no map name (it is Series-State-sourced); the adapter takes it as a parameter and records it in MD_MAP. It is never fabricated.
  3. Reversals are drop-before-seal (batch ingestion; the live path is rule 11). A !action sub-event carries revertedEventId naming the event it retracts; the retracted event is dropped before emission, so a reverted-then-reissued event emits exactly once — history is never rewritten (§7 rule 6 / the corrections-are-events discipline). The one real observed instance (the banked capture, seq 19-24) is a grid-invalidated-series / grid-validated-series-bracketed burst — game clock, round freezetime, game start, and series↔game association all reverted together — that resolves under a different game id than the one invalidated (9a12f1c6… → 8598eef4…, hence a different derived match_id, rule 1); this is a match-abandonment episode gated by rule 9a's validity toggle, not a same-match round correction. Batch ingestion handles it correctly because rule 9a already refuses/accepts on final validity — the drop-before- seal rule here just means none of the burst's five reverted sub-events ever reaches classification.
  4. Roster from the event universe. GRID Series Events carries no roster; the 10 canonical slots (0..=9) are the players observed as attacker/victim of player-killed-player, ordered by ascending GRID player id. A side that never appears is shorthanded (SC-3), never padded. teamId is stable per match; a change refuses the series (recorded reason, never guessed).
  5. Sides are per-round. CS2 sides swap at the half, so each round's T/CT is read from that round's attributed kills (actor/target side ∈ {terrorists, counter-terrorists}); the un-observed team is the opposite; an event-less round inherits the previous round's mapping. round_start t_slots / ct_slots follow this per-round resolution.
  6. Clock. replay_ts = the envelope sequenceNumber (the monotonic per-series order — the only clock); seq = the running emission index (a §4 tiebreak within equal replay_ts). The adapter enforces non-decreasing sequenceNumber (a regression refuses the capture), rather than trusting the feed's ordering. Wall-clock strings (occurredAt) are never read into a record (the no-wall-clock determinism law).
  7. Weapon. weapon_id is the 1-based position of the weaponKills token in a recorded observed-token table; an unseen or ambiguous (multi-token) weapon → a reserved UNKNOWN id, weaponKills absent/empty → 0 (none) — a kill is never dropped for its weapon (metadata, not a settlement key).
  8. Emission order (per §6.12 rules 10–11): match_discovered → match_live → per round round_start → kills (in sequenceNumber order) → round_end (finality Canonical, reported kills as the diagnostic cross-check) → match_complete. Every player-killed-player has a player attacker; the no-player-attacker death forms are not in GRID's declared CS2 vocabulary and are not emitted (deferred, TBC).
  9. Series validity, and deferrals. (a) Validity: grid-invalidated-series / grid-validated-series toggle the series' validity (the last wins); a series whose FINAL state is invalidated is refused at the edge (Ineligible::InvalidatedSeries), never silently ingested. (The loopfeed's invalidate-then-revalidate restart therefore stays valid.) Batch is this rule as stated; the LIVE path's incremental mirror is rule 13. (b) Deferred (named, not faked): bomb_event/v1 — GRID exposes no bomb site, so no bomb event is emitted (never a fabricated site); tick_state positions — GRID CS2 events carry no positions (Series-State-sourced, at snapshot cadence). Dota2/LoL derivations (which additionally propose new canonical vocabulary) are the successor sections.
  10. Replay-determinism gate on real data: the §6.1 records this section derives are folded into the game-state stream (§6.2), as state/v1 records, by the real ReplayEngine/Cs2Projector, and two independent folds of the same real GRID match log must digest byte-identical through the Flow-6 verifier (verify::diff_runs). The fold is a pure function of its input stream (the determinism bans — no wall-clock, RNG, or unordered iteration — hold on the per-event replay path), so rerunning it is identity. This is a standing CI gate, not a one-off check; it is the proof that a GRID-sourced segment replays exactly as any other §6.1 producer's.
  11. revision/v1 is real (CS2 only) — but this rule proves the CONSUMING machinery, not a complete live-path answer for the one real reversal precedent, which needs rule 9a's validity gate instead (see below). Batch ingestion sees a complete capture and can drop a reverted sub-event before it is ever classified (rule 3); a live capture must decide what to emit before knowing whether a later message reverts it, so revision/v1 (§7 rule 6, previously decoded but never forwarded or folded by anything) is made real: Cs2Projector now forwards it through the §6.2 stream, and all three declared §6.2 consumers — StateProjection, Cs2MarketPolicy, and pricing::feature::FeatureExtractor (which independently dispatches the same stream and would otherwise halt with UndeclaredSchema the moment a live match log forwarded one) — declare it a no-op (audit-only — it carries supersedes_offset, corrected_schema, corrected_body, but no consumer ever reads corrected_body to alter anything; every real state change stays a separately-appended record, never an edit, matching Flow 5's "we never edit — we append compensating events"). The intended shape this machinery is FOR: a reversal whose corrected replacement arrives in the SAME match log (e.g. a same-game round_start correction) emits round_restored/v1 for the reverted round — the pre-existing, CS2-native void machinery (StateProjection resets the roster, Cs2MarketPolicy voids the epoch, EpochVoided unwinds markets/orders/settlement end to end; this machinery predates GRID entirely, made for CS2's own mp_backup_restore) — plus the revision/v1 audit record, plus the corrected event emitted normally. This path is tested (a real round_restored → revision → reopened-round sequence, byte-verified) and safe to bind if a same-match correction is ever observed. It is NOT what the one real observed reversal is, however: the banked burst (rule 3) resolves under a different match_id — no in-match correction exists for it to target, so round_restored/revision never fire for that case at all. The actual live-path answer for THAT case is gating on rule 9a's validity toggle (never commit game-state or open markets while a series reads invalidated, so revalidating — even under a different game — needs nothing to unwind because nothing was acted on yet); that gate is rule 13, distinct from this rule. Separately, the same match-abandonment shape also means the reissue's sub-events arrive under a DIFFERENT game-started-round.actor.id than this live session's own — rule 13 also covers that half (a game-id change refuses, Ineligible::GameChangedMidSeries, rather than the live fold's prior silent drop of the new game's events). A reversal targeting anything this rule doesn't name (a kill, structure, objective, or an economy event carried by a title's canonical vocabulary) is refused by the live path (typed halt, never a guess) — note this is narrower than "never observed": LoL's banked capture DOES carry real economy-event reversals (player-!purchased-item, 9 instances, banked capture), but they target a GRID Context event with no canonical mapping (the GRID LoL mapping note §2.5) and are therefore inert regardless — the refusal clause is about canonicalized records, not about "nothing else is ever reverted." The live-specific incremental normalizer that PRODUCES revision/v1 records (as opposed to the fold/policy machinery that CONSUMES them, proven by this rule) is rule 12's (LiveFold) — it additionally depends on resolving GRID's missing-roster problem (rule 4: slots derive from ascending id across ALL observed players, unknowable live until the match ends) and the validity- gate design above. Scope: CS2 only — Dota2/LoL's game-state streams forward only the match envelope and outcome (the match-winner forwarding sets, §6.11.1/§6.14.1/§6.15.1 — no reversal vocabulary) and their !action live-path handling is undefined until a §6.14/§6.15 successor rule adds one.
  12. The live-specific incremental normalizer, grid_edge::normalize::cs2_live::LiveFold — resolving rule 4's missing-roster problem for the live path specifically (batch is unaffected — rule 4 stands unchanged for it). Two things a live consumer cannot do that batch can (full lookahead over a complete capture): (a) Roster source. Rule 4's ascending-id slot rule is UNCHANGED, but its INPUT is not: rule 4's roster (players observed as attacker/victim of player-killed-player, discovered gradually as kills happen) is live-unsafe — a player first seen mid-match with a lower id than an already-slotted player would retroactively renumber records already emitted. The live fold instead sources its roster from GRID's OTHER feed, Series State (the GRID CS2 mapping note §4.3's SegmentState.teams[].players[]), which names the full 10-player roster from round 1 — promoted here from an OPTIONAL cross-check (this section's rule 4 / the GRID CS2 events-mapping note §4, the text) to the live path's REQUIRED primary roster source. The fold's slot table is fixed once from the FIRST observed round's roster and never changes after; a later round's roster naming a player outside that set refuses (Ineligible::RosterChangedMidMatch) rather than guess. (b) Reversal decision. classify_reversal decides, per !action: a reversal targeting the CURRENTLY-OPEN round's own game-started-round is round-start-class (rule 11's mp_backup_restore shape) — the fold resets its open-round bookkeeping so the reissue is accepted fresh, and signals ReversalDecision::RoundStartClass for the caller to act on. Anything else (an unknown target, a kill/round_end/match-boundary reversal, or a round that already closed) refuses (ReversalDecision::Refuse) — matching rule 11/rule 3's own finding that the ONE real observed reversal is a match-abandonment under a DIFFERENT game id, not a same-match correction, and this fold must not guess otherwise in real time. Deliberately NOT here (one layer up, the live driver): constructing the actual round_restored/revision RECORDS rule 11's machinery consumes. RoundRestored::superseded_from_offset is a match-log OFFSET (Flow 5: "the Ingestor" appends it) that this fold — pure, offset-agnostic, per grid-edge's own invariant of never touching SegmentWriter/offsets — does not have. classify_reversal supplies only the decision; grid_edge::live::Cs2LiveDriver owns the SegmentWriter, tracks the real offset of every appended round_start, and constructs the compensating round_restored→revision→reissue sequence when the fold signals RoundStartClass — proven against a real SegmentWriter/SegmentReader round-trip, tests/live_driver.rs. The driver is still transport-agnostic (it takes one already-parsed sub-event at a time, the same shape this fold's feed takes); the actual wss://api.grid.gg dial and the Series State HTTPS poll are the network edge. The edge's two slices: slice 1 (grid_edge::net::state_poll:: poll_series_state, x-api-key-authenticated GraphQL POST against the GRID data-source note §3b's confirmed endpoint, handing raw bytes unchanged to the existing source::cs2_state::parse_round_rosters); slice 2 (grid_edge::net::events_ws, the wss://api.grid.gg Series Events dial, ?key= query-param auth per the GRID CS2 events-mapping note lines 49-54, each WS text frame handed to the shared source::parse_one_envelope — the same envelope parser the batch file path now uses too). main.rs's live subcommand (implemented in live_capture::run) wires both into a real single-session live capture: Cs2LiveDriver::observe_roster fed once from a Series State poll, Cs2LiveDriver::ingest fed per sub-event as Series Events frames arrive, sealed when a match end is observed in an envelope (a stall still open at that moment ending with the run) or on a clean socket close unless the series reads invalidated (rule 13) or, with no match end observed, a feed stall is open (point (d): no match_complete synthesized, nothing sealed). No auto-reconnect on a dropped Events socket — the feed documents no resume contract beyond the connect-time fromSequenceNumber cursor, and inventing one would be guessing at an undocumented protocol, not implementing it (net/events_ws.rs doc). Series Events consuming live unauthenticated network input is also this workspace's first fuzz-target trigger (source/mod.rs's fuzz-posture note) — crates/grid-edge/fuzz/ fuzz_targets/parse_one_envelope.rs fuzzes the shared envelope parser, seeded from the real captured fixture. (c) Series validity is rule 13's. Under this rule grid-invalidated-series/grid-validated-series fold as a no-op — invalidation is never eager or permanent here, which would contradict rule 9a's own "last wins" rule (the loopfeed's invalidate-then-revalidate restart "therefore stays valid"). classify_reversal (point (b)) is this fold's live-anomaly guard for REVERSALS specifically; rule 13 is the series-level guard alongside it. (d) The feed-health records — the stall detector. The capture writes three title-agnostic sys/ records into the match log beside the game events (risk-engine.md §3 R7, §8 E6, §11), through the driver's one SegmentWriter beside its event appends (one writer, one next_seq). feed_watch_armed/v1 {rule_ms, continuity} comes FIRST, at stamp 0, before any envelope's record — the rule the capture applies, taken from its command line. feed_stalled/v1 {source, last_seq, rule_ms} is appended when rule_ms passes with no Series Events ENVELOPE — next_envelope keeps the instant of the last envelope and hands the deadline adapter under the socket the ABSOLUTE instant the rule's span ends, checked before every read of the socket, so a frame trickled a byte at a time trips the stall as a silence does; a ping never moves the envelope clock, so a provider that pings and sends nothing still trips the stall (source 0, last_seq the last consumed envelope's sequenceNumber; the detector is armed at dial time, so a silence before the first envelope stalls with last_seq the dial cursor, 0 on this path, consistent with the provider's first sequence under contiguous); when a Pending round's Series State re-poll fails (source 1, last_seq the round's segment ordinal; the poll retried on each later envelope until it succeeds, the round's sub-events and every later one HELD in order behind it until the roster arrives or the match ends, bounded by the held-round caps of model-plugin-layer §16, and fed after feed_recovered{source: 1}; a match end arriving while the round is held is fed at once and completes the match, the held round ending with the run, and from the match end nothing is held and the capture writes no feed record); and on a transport or IO error, before the process exits FAILURE (source 0 — the pulls are in the record even when the socket dies; the .partial stays unsealed); a malformed envelope, a sequence regression or a binary frame ends the capture FAILURE with no stall record. Armed, the socket's writes carry a deadline, each write capped at the time left before the read's, so a Pong reply the peer never drains never delays the stall and past the write deadline is a transport or IO error; after a reported stall the read keeps an hour's deadline from the last frame, so a link dead without a FIN or RST is one too; and under a rule the roster poll spends at most ONE attempt per envelope, its request timeout the lesser of the State request timeout and half of rule_ms, at least 1 ms, so a round still pending after its envelope's one poll is a failed poll (risk-engine.md §12 C102). feed_recovered/v1 {source, last_seq, first_seq, continuity} is appended BEFORE the next envelope's records once that envelope passes the continuity rule — contiguous: its sequence is the stall's last_seq or the next; increasing: at or above it, a regression refused by the socket already — or once the retried poll supplies the roster (source 1, first_seq = last_seq), which lets the round start proceed; a gap under contiguous ends the capture as a transport or IO error does. The stall and the recovery are stamped with the last match-log record's replay_ts (an equal ts is legal; 0 when only the armed record precedes them); no game-event record intervenes between a stall and its recovery on either source — the events source has no envelope between them and the state poll holds the round — so the recovery's stamp is the stall's. No value read from the clock enters a record: the clock decides only WHETHER a stall record is appended, at the one allow-listed Instant site of grid-edge, in net/events_ws.rs (architecture.md determinism rule 2). Silence is legitimate in a live match (freeze time, a technical pause), so the threshold sits above the longest legitimate quiet stretch, measured per provider (risk-engine.md §12 C103). A re-dial from fromSequenceNumber=0 (point (b): no auto-reconnect) writes a NEW capture and is never a recovery of the old one's run. A clean socket close with no match end observed while a stall of either source is open refuses to seal (Ineligible::FeedStalled { feed_source }), as the invalidated-series case does: no match_complete is synthesized, nothing is sealed, the .partial stays unsealed, and the run runs no further tick (state-layers §11); a match end observed in an envelope completes and seals the match as it does today, a stall still open at that moment ending with the run (state-layers §13). The detector disarms at the match end: once the match end is observed no feed record is written, and the close seals as it does without a rule.
  13. The live series-validity gate — rule 9a's incremental mirror. (a) is closed and proven; (b) closes the silent-wrong-data half of the game-UUID-swap precedent with a typed refusal, but does not recover the surviving game's history — an auto-reopen is need a separate, unscoped design change first (see below). (a) Fold unconditionally, refuse once at the end — batch's own rule, not eager/permanent refusal AND not mid-stream suppression either. LiveFold::feed applies every Cs2Event::Validity toggle as a PURE state update (never itself a record); every OTHER sub-event folds through with NO gating on the current validity state at all — suppressing folding while currently invalid would be a parity break: batch's normalize::cs2::collect folds every event UNCONDITIONALLY and reads valid only once, at the very end — suppressing mid-stream would silently and permanently drop real game-state for a series that later revalidates, which batch would have kept. The FINAL validity (last toggle wins) is checked every time LiveFold::finish runs — whether triggered by a real Cs2Event::MatchEnd mid-stream, or by Cs2LiveDriver::close — refusing Ineligible::InvalidatedSeries — the exact incremental mirror of batch's normalize::cs2::collect last-wins check. finish is idempotent only in the narrow sense of never emitting a second match_complete/v1 — NOT in the sense of a stable return value: a toggle arriving AFTER a MatchEnd-triggered first, successful call can still fail a later call (Cs2LiveDriver::close's own), deliberately — a MatchEnd mid-stream can durably commit match_complete/v1 before a later invalidation is known, and checking self.complete before validity would silently seal a segment claiming completion for a series GRID itself later invalidated (regression-tested, tests/live_driver.rs::close_refuses_after_a_late_invalidation_ following_a_committed_match_complete, which also proves the resulting file is left unsealed). (Reversal handling, point (b) below and rule 3, stays its own separate revertedEventId pre-pass either way — both batch and live drop a reverted event unconditionally, independent of the validity toggle entirely.)

Scope this depends on: folding unconditionally through an invalidated window is safe ONLY because nothing consumes this driver's segment before close seals it. d2-grid live writes to a plain file no live-tailing consumer reads — but stream_core::segment::tail::SegmentTailReader / runtime::Scheduler::run_tailing (Stream 0e) exist specifically to tail a growing, UNSEALED root-stream segment, and this driver's match log is appended via Durability::Root (genuinely fsync-durable per call) — exactly the kind of segment run_tailing consumes live. If a change ever wires a GRID-live match log into run_tailing while still being written, this design would need revisiting: a tailing consumer could see records folded during a window GRID itself later disowns, before this fold's own end-of-capture validity check ever runs. Not attempted here — separate, unscoped work. The feed-health records of rule 12 (d) are appended to the same segment by the same writer and change nothing here: a capture that ends on a transport or IO error leaves its stall record as the unsealed .partial's last record. (Point (b) below's game-id-swap reopen is out of scope; see that point.) (b) The game-UUID-swap half of the same precedent — a typed refusal, not a reopen. A live fold that silently dropped a game-started-round naming a DIFFERENT game id than the one it first anchored to ("one fold per game; the driver routes by game_id") would, under d2-grid live's single-session driver, which never routes multiple games, seal a segment that LOOKS complete while quietly missing every event after a live match-abandonment swap (rule 3's real precedent), with no error. So a game-id change refuses (Ineligible::GameChangedMidSeries{expected, got}) instead of silently dropping — proven at the FOLD level: tests/cs2_live.rs's real_fixture_game_id_swap_refuses_at_the_surviving_games_own_first_ round_start replays the real captured tests/fixtures/cs2_grid_slice. jsonl through LiveFold::feed directly and confirms a refusal at the surviving game's own game-started-round, seq 25 (game 8598eef4…, tests/conformance.rs's own canonical match id for the batch path).

This only converts a silent-wrong-data failure into a loud one — it does not recover the surviving game's history. Reopening a fresh driver for the new game id, so a live capture could recover the full match rather than just refusing loudly, is NOT a small addition: the seq 19→23 invalidate/revalidate bracket that would identify a reopen-eligible swap also carries a !action reversal burst (rule 3/rule 11) whose targets (game-!set-gameClock, round-!started- freezetime) are neither round-start-class — LiveFold:: classify_reversal correctly refuses both as RefuseReason:: UnknownTarget (rule 11/12(b), a deliberate rule: ANY refused reversal permanently poisons Cs2LiveDriver — the conservative "stop live-processing on anything that isn't round-start-class" design) — so the driver halts at seq 20, five sequence numbers before the seq-25 game-id swap a reopen would key off of. Making a reopen reachable on this real precedent would require first revisiting that poisoning rule (whether an UnknownTarget/ NotRoundStartClass refusal occurring INSIDE an active invalidate/ revalidate bracket should poison the driver at all, given point (a)'s own "fold unconditionally, decide once at the end" philosophy) — a separate, unscoped design question about core reversal semantics, not attempted here. (c) Series State's OWN validity field, closed alongside — but see the production-query gap below. The GRID CS2 mapping note §4.5 documents seriesState.finished/forfeited/valid/invalidationReason as governing "whether the series is ingestible at all" — but source::cs2_state::parse_round_rosters never parsed or checked ANY of them. It is checked first, before any game or roster is read: valid == false refuses (GridError::InvalidatedSeriesState{reason}, carrying GRID's own invalidationReason, control-character-stripped). valid is banked-introspection-confirmed Boolean! (non-null in the banked live introspection of the series-state schema), so absent-here is defense-in-depth against a malformed/older response, not open schema uncertainty; a present-but-wrong-typed valid still refuses (closed-world, never guessed around). finished/forfeited remain unread — no rule yet motivates a use for them beyond valid/invalidationReason, left for a later rule if one does. The production query selects what the check reads: net::state_poll:: SERIES_STATE_QUERY SELECTS valid/invalidationReason — GraphQL returns only the requested selection set, so a check reading a field the query omits is silently dead against the real API while a Tier-2 fixture trimmed from a wider ad-hoc capture than the query's own would still pass; the query selects both fields as seriesState siblings of games, matching what parse_round_rosters reads.

6.14 GRID Dota2 source derivation (Series Events → the attributed vocabulary) #

Why this section exists: §6.11 deferred killer/team attribution "to a feed that carries them"; GRID Series Events is that feed. Unlike CS2 (§6.13, which mints nothing), GRID Dota2 motivates new canonical events — the attributed vocabulary in the dota2 block 1003..=1007 (ingest::dota2::grid; new ids, never reinterpretations of the §6.11 ids, §7 rules 1/3). The grid-edge adapter emits them; partner-specific detail lives in the gitignored GRID Dota2 mapping note.

New canonical events (all in the dota2 "dota2/" block): match_discovered/v2 (1003), match_complete/v2 (1004), hero_death/v2 (1005), structure_destroyed/v1 (1006), hero_respawn/v1 (1007).

Normative derivation rules (grounded on a live Dota2 capture):

  1. Match identity. One canonical match = one GRID game. match_id = BLAKE3( "grid/dota2/match/v1" ‖ game-UUID) from series-started-game.target.id; source_ref = BLAKE3("grid/dota2/series-events/v1" ‖ u64-LE(len) ‖ bytes). match_discovered/v2 carries only {match_id, source_ref} — no roster: GRID gives heroes as a team-level draft (team-picked-character), not per-slot, and each attributed event self-describes its side.
  2. Slots from the event universe. The players observed as attacker/victim of player-killed-player (and in tower/respawn events), ordered by ascending GRID player id, are slots 0..=N (N ≤ 9 — Dota2 has exactly 10 slots). Dota sides (radiant/dire) are stable — no half-swap — so a player's side is constant; a change refuses the series.
  3. Deaths (attributed + non-player). Both player-killed-player and game-killed-player → hero_death/v2 {victim_slot, killer_slot, killer_side}: a player-killed-player carries the killer's slot+side; a game-killed-player (killed by the game — neutrals, a tower, the fountain, a deny) carries killer slot+side null together — both are real scoreboard deaths and both emit. Victim HD_SLOT carries v1's id forward (§7 rule 3). player-multikilled-player and player-completed-NKillStreak are aggregates derived from the individual kills (not distinct deaths) — recorded as context, never emitted, or the death count double-counts.
  4. Structures. player-destroyed-tower → structure_destroyed/v1 {side, kind: Tower, lane, tier, destroyer slot/side}, parsing target.id "{side}-tower-{lane}-{tier}" (e.g. dire-tower-middle-1). Barracks, ancient, roshan, outposts are GRID-declared but unobserved on the loopfeed — deferred, never minted/emitted untested (rule 7).
  5. Respawns. game-respawned-player → hero_respawn/v1 {slot}.
  6. Clock & reversals. replay_ts = the envelope sequenceNumber (enforced non-decreasing); wall-clock never read. !action reversals are drop-before-seal for batch ingestion (§6.13 rule 3 / §7 rule 6); no live-path handling exists yet (§6.13 rule 11's scope is CS2-only). match_complete/v2 {radiant_win} is emitted only when a winner is observed (team-won-game/series-ended-game/team-won-series) — a partial capture has no outcome, and one is never fabricated.
  7. Deferred (named, not faked): roshan / barracks / ancient / outpost events (unobserved on the loopfeed); the team-level hero draft detail; Dota2 settlement (the match-winner market/settlement is §6.14.1's forwarding set + §6.9; live serving is the betty FRAME log's — §6.11.1; a GRID-fed state/v1 serve has no feature extractor and is not dispatchable, the hazard §6.14.1 names). The replay projector itself is §6.14.1's (below). LoL is its own successor section.

6.14.1 Dota2 game-state stream (derived from the match log by Replay) #

The attributed match-log folds into a state/v1 game-state stream (the Dota2 analogue of §6.2; the global registry name is dota2/state/v1, schema id 1008, per the §6.11 code-is-the-registry rule — the body below is written bare, the title scoped by this section exactly as §6.2 writes cs2's state/v1). Unlike cs2 — which samples a rich per-player snapshot on each tick_state — the GRID Dota2 feed carries no periodic tick; it is a stream of discrete events. So the fold is a per-match cumulative accumulator that emits the updated snapshot after each state-mutating event (hero_death/v2, hero_respawn/v1, structure_destroyed/v1, match_complete/v2); the identity match_discovered/v2 changes no tally and emits no snapshot (it is forwarded verbatim — the forwarding set below). Offset of this stream IS state_version (§4).

Event Body fields
state/v1 per-side {kills, towers_lost} for Radiant and Dire; per_slot_alive[10]; per_slot_deaths[10]; winner (Null until observed) — offset IS state_version
forwarded events match_discovered/v2 and match_complete/v2 pass through with original bodies and envelopes (the forwarding-set paragraph below; the §6.2 shape); structure_destroyed/v1 too — folded into towers_lost AND forwarded, its snapshot appended FIRST and the forwarded record following with the same replay_ts/seq (the emission order §6.15.1 pins), so such an event occupies TWO offsets of this stream (state_version, §4 — the §6.15.3 stamp-discontinuity rule applies to this stream too)

The per-slot arrays are the fixed 10-slot Dota2 universe (§6.14 rule 2); a slot for a player the capture never observed reads alive=true, deaths=0 — the identity of the fold, not an observation (it invents no HP, gold, or position).

Fold rules (only observed-derivable state; nothing invented or hardcoded):

  1. Kills per side credit the killer's side on an attributed death; hero_death/v2 carries no victim side, so cs2's enemy-only refinement has no analogue here, and a non-player death (killer = None) credits no side.
  2. Towers lost per side counts structure_destroyed/v1 by the side the structure belongs to — a destruction tally, not a "towers remaining" count (no feed event establishes a starting inventory, and the map topology is never hardcoded).
  3. Per-slot death/respawn signals are the per-hero state the feed observes. A hero_death/v2 increments the victim's per_slot_deaths[slot] (a cumulative tally) and sets per_slot_alive[slot] false; a hero_respawn/v1 sets alive true again. alive[slot] starts true (no death observed). No HP / gold / position is folded — the feed carries none.
  4. Winner is Null until match_complete/v2 is observed; a second completion is a contradiction and refuses.
  5. The fold is pure (no wall-clock, RNG, or unordered iteration), so two independent replays of the same match log are byte-identical — the determinism gate on real data (verify::diff_runs, flows.md Flow 6), the same gate cs2 carries (§6.13 rule 10).

Forwarding set: match_discovered/v2 and match_complete/v2 pass through to this stream with their original bodies and envelopes (the §6.2 shape cs2 uses for round_end / round_restored) — the signals the match-winner lifecycle consumer (replay::match_winner::MatchWinnerPolicy bound to replay::dota2::Dota2) opens the match's market at and closes it at (§6.9 market_created row, settlement-contract SC-7). The betty archive's match_discovered/v1 / match_complete/v1 pair and its frame log's roster_side/v1 envelope are in the same binding's MATCH-LOG read-set (replay::dota2::Dota2::FORWARDS is the union of both blocks — five ids, six with structure_destroyed/v1, which this state projector folds into towers_lost AND forwards verbatim) for the corpus-trained dota2/baseline-v1 path; the betty FRAME projector (§6.11.1) forwards them onto ITS stream. The dota2/v1/v2 extractor reads that frame stream, not this one; it emits nothing for the union — and binds its sides at roster_side/v1 (§6.11.1). A GRID Dota2 serve cannot feed this state/v1 stream to that extractor at all: the runtime's dota2 title binds the frame projector (model-plugin-layer §12), and a GRID-fed Dota2 serve waits on an extractor over state/v1 — a recorded hazard, not a promise.

6.15 GRID LoL source derivation (Series Events → a new title vocabulary) #

Why this section exists: League of Legends is the third title and the first minted entirely from a real partner feed — there is no legacy/demo LoL vocabulary (LoL was absent from the 2023 corpus), so the whole block is new. The canonical LoL vocabulary lives in the lol/ block 2000..=2999 (ingest::lol; §7 rule 4), emitted by the grid-edge LoL module from GRID's Series Events feed. Partner-specific detail lives in the gitignored GRID LoL mapping note.

New canonical events (all in the lol "lol/" block): match_discovered/v1 (2000), match_complete/v1 (2001), player_killed/v1 (2002), structure_destroyed/v1 (2003), objective_slain/v1 (2004). As §6.1's, this match log may carry the three universal sys/ feed-health records (§6.13 rule 12 (d); risk-engine.md §3 R7).

Normative derivation rules (grounded on a live complete-game LoL capture):

  1. Match identity. One canonical match = one GRID game (a LoL series is best-of-N; the edge ingests one game per call and refuses a multi-game capture, never folding two games into one match). match_id = BLAKE3("grid/lol/match/v1" ‖ game-UUID) from series-started-game.target.id; source_ref = BLAKE3("grid/lol/series-events/v1" ‖ u64-LE(len) ‖ bytes). match_discovered/v1 carries only {match_id, source_ref} — no roster: GRID gives champions as a team-level draft (team-picked-character), not per-slot, and each attributed event self-describes its side.
  2. Slots from the event universe. Players observed across kill/structure/objective events, ordered by ascending GRID player id, are slots 0..=N (N ≤ 9). Sides (blue/red) are stable — a mid-match change refuses the series; >10 distinct players refuses.
  3. Kills. player-killed-player → player_killed/v1 {victim_slot, killer_slot, killer_side} (killer slot+side null together for a non-player death — the byte shape supports it; the loopfeed carried only attributed kills). player-multikilled-player is an aggregate of the individual kills (not a distinct death) — context, never emitted.
  4. Structures. structure_destroyed/v1 {kind, side?, lane?, tier?, destroyer?} covers four kinds. The rich {player,team}-destroyed-{tower,fortifier,base} form carries the structure identity in target.id: tower "{side}-turret-{lane}-{tier}" (lanes top/mid/bot/base, tier 1..=3), inhibitor "{side}-inhibitor-{lane}" (no tier), nexus "{side}-nexus" (no lane/tier, game-ending). Turret plates exist only as {player,team}-completed-destroyTurretPlate{Top,Mid,Bot} — lane is in the type, the structure-side is not in the bytes (side = None). A team-* form has no player slot (destroyer = None). The -completed-destroy{Tower,Fortifier,Nexus} events are aggregate mirrors of the -destroyed- form (0 orphans, verified) — context, never emitted.
  5. Objectives. player-killed-{S,A,B}TierNPC → objective_slain/v1 {tier, objective, slayer_slot, slayer_side}. The kill's target.id is the specific monster name (baron, hextechDrake, riftScuttlerBot, …), recorded source-verbatim. Every -completed-slay* named-slay co-fires with a TierNPC kill on the same (correlationId, sequenceNumber) (0 orphans, verified) — the TierNPC kill is the single canonical anchor; the named-slay companions are context, deduped away (so one objective kill emits exactly one objective_slain, never the 2–3 co-firing representations).
  6. Clock & reversals. replay_ts as §6.14 rule 6 (the envelope sequenceNumber, non-decreasing; wall-clock never read). !action reversals are drop-before-seal for batch ingestion (§6.13 rule 3 / §7 rule 6); no live-path handling exists yet (§6.13 rule 11's scope is CS2-only — LoL's real capture carries genuine economy-event reversals, player-!purchased-item, but they target a Context event with no canonical mapping, §2.5, and are therefore inert regardless). match_complete/v1 {blue_win} is emitted only when a winner is observed (team-won-game/team-won-series, blue_win from the winning actor.state. side); a partial capture has no outcome, never fabricated.
  7. Deferred (named, not faked): genuinely-unobserved vocab — Infernal/Elder drake (the leg-7 tally counts them BY NAME if they appear, §6.15.1 — none was observed in the the banked capture), Ruinous/Voracious Atakhan, forfeits, and the non-player death forms (game-killed-player / team-killed-player / player-selfkilled-player, 0 observed — the player_killed/v1 null-killer path is reserved for them); multi-game series splitting; positions; LoL settlement (the match-winner market/settlement is §6.15.1's forwarding set + §6.9; live serving — the lol run-manifest title binds the §6.15.3 state/v2 projector, model-plugin-layer §12). The replay projector itself is §6.15.1's (below).

6.15.1 LoL game-state stream (derived from the match log by Replay) #

The lol match-log folds into a state/v1 game-state stream (the LoL analogue of §6.2; the global registry name is lol/state/v1, schema id 2005, per the code-is-the-registry rule — written bare here, the title scoped by this section, exactly as §6.2 writes cs2's state/v1 and §6.14.1 dota2's). Like dota2 (and unlike cs2) the GRID feed carries no periodic tick, so the fold is a per-match cumulative accumulator that emits the updated snapshot after each state-mutating event (player_killed/v1, structure_destroyed/v1, objective_slain/v1, match_complete/v1); the identity match_discovered/v1 changes no tally and emits no snapshot (it is forwarded verbatim — the forwarding set below). Offset IS state_version (§4).

Event Body fields
state/v1 per-side {kills, structures_lost, objectives[3] by tier S/A/B} for Blue and Red; per_slot_deaths[10]; turret_plates (side-agnostic); winner (Null until observed); nexus_fallen (Null until observed) — offset IS state_version
forwarded events match_discovered/v1 and match_complete/v1 pass through with original bodies and envelopes (the forwarding-set paragraph below; the §6.2 shape); structure_destroyed/v1 and objective_slain/v1 too — folded into the snapshot AND forwarded (the frame reader's tally), each such event occupying TWO offsets of this stream: its snapshot FIRST, the forwarded record next with the same replay_ts/seq (the emission-order rule below)

The per_slot_deaths array is the fixed 10-slot LoL universe (§6.15 rule 2, N ≤ 9); a slot for a player the capture never observed reads 0.

Fold rules (the §6.14 standard — only observed-derivable state):

  1. Kills per side credit the killer's side on an attributed kill; player_killed/v1 carries no victim side, so cs2's enemy-only refinement has no analogue, and a non-player death (killer = None) credits no side.
  2. Per-slot deaths are a cumulative tally incremented at the victim slot on each player_killed/v1. There is no alive flag — LoL respawns ARE observed in the raw GRID feed (game-respawned-player) but are context only (the edge drops them, not canonicalized), so the lol/ vocabulary carries no respawn event and the fold has nothing to toggle alive with; folding one would mean inventing a revive the vocabulary doesn't carry. (Contrast dota2, which canonicalizes hero_respawn/v1 and so tracks alive.)
  3. Structures lost per side count structure_destroyed/v1 by the side the structure belongs to (tower / inhibitor / nexus) — a destruction tally, not "standing" (no feed event gives a starting inventory; the map is never hardcoded). A turret-plate carries no side (§6.15 rule 4), so plates are counted in a side-agnostic turret_plates total rather than invented onto a side; a non-plate structure without a side is malformed and refuses.
  4. Objectives per side credit objective_slain/v1 to the slayer's side, bucketed by tier [S, A, B].
  5. Winner is Null until match_complete/v1; a second completion contradicts and refuses. nexus_fallen is Null until a Nexus structure_destroyed is observed — the side whose nexus fell (game-ending), an independently-observed cross-check of the winner. A nexus falls once, so a second nexus destruction contradicts and refuses too (symmetric with the completion guard — refuse contradiction, never overwrite).
  6. The fold is pure (no wall-clock, RNG, or unordered iteration), so two independent replays of the same match log are byte-identical — the determinism gate on real data (verify::diff_runs, flows.md Flow 6), the same gate cs2 (§6.13 rule 10) and dota2 (§6.14.1) carry.

Forwarding set: match_discovered/v1 and match_complete/v1 pass through to this stream — and to the §6.15.3 state/v2 stream, whichever lol projector a run binds — with their original bodies and envelopes (the §6.2 shape cs2 uses for round_end / round_restored): the signals the match-winner lifecycle consumer (replay::match_winner::MatchWinnerPolicy bound to replay::lol::Lol) opens the match's market at and closes it at (§6.9 market_created row, settlement-contract SC-7). structure_destroyed/v1 and objective_slain/v1 pass through too, BESIDES being folded into the tallies above: the title's frame READER (replay::lol::LolFrameReader, a running replay::tally::Tally) counts off them what no snapshot field answers — towers TAKEN per side (the other side than the fallen tower's owner; plates, inhibitors and the nexus are not towers), dragons and barons slain per slayer side by the objective NAME (the six drakes + elderDragon; baron — replay::tally::DRAGON_OBJECTIVES / BARON_OBJECTIVE; the tier buckets above merge them with the herald, grubs and and, on the GRID path, whatever else tier A records) — read at each snapshot by FrameSource<Tally> for the tally families (settlement-contract SC-12). The name sets (normative): DRAGON = {cloudDrake, mountainDrake, oceanDrake, infernalDrake, chemtechDrake, hextechDrake, elderDragon}, BARON = {baron} — the Riot edge's monster table is pinned to them by its own test; an objective outside both sets (the herald, the void grubs, a scuttler, any name rule 7 lists as unobserved) counts toward NO tally — the rule is the name, never the tier, and an unknown name is not a refusal (the market is dragons or barons, not "tier A"). A tower is credited to the OTHER side than its recorded owner (side, fold rule 3); the record's optional destroyer pair is deliberately NOT read (the team-* form carries none, §6.15 rule 4). The emission ORDER (normative, both lol projectors and the dota2 state projector of §6.14.1): where one record both mutates the state and rides the forwarding set, the derived snapshot is appended FIRST and the forwarded record follows, carrying the same replay_ts and seq — two records of a derived stream may therefore share a clock, which §4's tiebreak sequence does not otherwise sanction for derived streams; the tally reads "the events before the snapshot" against this order. On this state/v1 stream a structure or objective event thus occupies TWO offsets (state_version, §4): the §6.15.3 stamp-discontinuity rule applies here as well. Every other consumer declares the set as a no-op (the lol/v1/v2 extractor, the corpus builder). The runtime serves a LoL market: the lol manifest title binds the state/v2 projector (the grid the extractor reads), the match-winner policy / router / outcome source and the lol/v1/v2 extractor (model-plugin-layer §12).

6.15.2 LoL per-minute player-frame source record (player_frame/v1) #

A canonical SOURCE record (schema id 2006; registry name lol/player_frame/v1, written bare player_frame/v1 here with the title scoped by this section, as §6.15.1 writes state/v1), NOT a replay-derived state: the Riot MATCH-V5 timeline carries a participantFrames block every frameInterval (60 s) with each of the ten players' economy + combat stats. The riot-edge adapter emits one player_frame/v1 per timeline frame, interleaved into the match log by replay_ts (a frame's mapped events first — each ≤ the frame's timestamp — then the frame's snapshot at the frame timestamp; replay_ts stays non-decreasing). Clock unit: replay_ts is the timeline item's timestamp — Riot milliseconds of game time (a frame every frameInterval, 60,000 in the feed we ingest; riot-edge stamps the feed timestamp unscaled, normalize/mod.rs), so 1,000 replay-ts units are one game second of this source (cs2: 64 per §6.12 rule 3; dota2: 30 per §6.11 rule 4). The GRID-fed LoL log stamps the envelope sequenceNumber instead (§6.15 rule 6) and carries no wall-time unit. It is the rich per-player substrate for the LoL feature path, the LoL analogue of the cs2 per-tick state.

  1. One record per frame, all ten players. Body: frame_index (the 0-based minute) and a fixed-arity players array of 10 maps. Players are slot-indexed (slot = participantId − 1). Side is teamId-authoritative — read from the match file (teamId 100 = blue, 200 = red; §6.15 structure), NOT from the pid range. Because Riot groups pids 1–5 as team 100 and 6–10 as team 200, the slot layout is consequently slots 0–4 blue, 5–9 red — an emission invariant the feature layer reads positionally (this record stores no side field).
  2. Each player carries the frame-level economy (total_gold, current_gold, xp, level, minions_killed, jungle_minions_killed, time_enemy_controlled, gold_per_second, pos_x, pos_y), a fixed-order champion_stats block (25 values) and a fixed-order damage_stats block (12 values). The two stat-block orders are alphabetical by Riot key name (the adapter looks each key up by name into that fixed array — it does not depend on the JSON object's key order), frozen by the adapter and arity-checked on decode.
  3. Integer-only, champion_stats SIGNED. Every value is a Riot-scaled integer on the feed, so the record is float-free by construction (§3 rule 1; core law 2) — no quantization, unlike the cs2 position/aim edges. damage_stats and every scalar field are non-negative (u64); champion_stats is SIGNED (i64) — real feed data observed armor, ccReduction, magicResist, and powerRegen all go negative (11/300 matches in a 300-match na1-challenger sample; the original 30-match sample this record was minted against never hit it). The whole champion_stats block is signed uniformly (not a per-key carve-out), matching the block's own pure-passthrough design.
  4. It does not mutate lol/state/v1. The game-state fold (§6.15.1) consumes the match-log events (2000–2004) only and SKIPS player_frame records, so lol/state/v1 is byte-identical whether or not frames are present. The per-minute economy reaches the model via the feature substrate (§6.15.3), not the v1 state stream.

6.15.3 LoL per-minute rich game-state snapshot (state/v2) #

A Replay-DERIVED stream (schema id 2007; registry name lol/state/v2, written bare state/v2 here), the per-minute modelling substrate and the LoL analogue of cs2/state/v2. lol/state/v1 (§6.15.1) is the coarse, event-driven cumulative fold; state/v2 is emitted once per player_frame/v1 (§6.15.2) and carries BOTH the cumulative game-state folded up to that minute AND that minute's per-player economy.

  1. One snapshot per player-frame. The v2 projector folds the match-log events (2000–2004) into the same cumulative StateProjection the v1 fold uses, and on each player_frame/v1 emits a state/v2 = the cumulative tallies so far (blue/red kills, structures_lost, objectives[S,A,B]; per_slot_deaths[10]; turret_plates) + the frame's players[10] economy block. A frame's events precede it in the log (§6.15.2), so the cumulative state at the snapshot reflects every event up to that minute. Events emit no snapshot (the cadence is per-frame); offset == state_version (§4). The §6.15.1 forwarding set rides this stream too: match_discovered/v1 / match_complete/v1 pass through verbatim (they are not frames, so they emit no state/v2), and structure_destroyed/v1 / objective_slain/v1 as well (folded into the snapshot AND forwarded, for the frame reader's tally). Stamp discontinuity: a forwarded record occupies an offset, so LoL corpus rows carry state_version stamps shifted by the forwarded-record count (+1 after the leading match_discovered; also by every structure and objective event before the frame) versus earlier exports; a re-export will not reproduce a banked dataset_split_hash (model-plugin-layer §10 — the spent LoL split stays spent), while vectors and labels are byte-identical. Dota2's own frame stream has the same discontinuity (§6.11 rule 11: the leading roster_side/v1 shifts every row by +1 — and by every forwarded structure_destroyed/v1 before the frame).
  2. Pre-outcome: no winner/nexus carried. A per-minute snapshot is a feature state, not a settlement state — the label (winner) is read from match_complete/v1 at corpus-build time, so winner/nexus_fallen are deliberately omitted from state/v2. The race-to-kills family (settlement-contract SC-8) IS decided on this snapshot: its per-side kills (the cumulative fold rule 1 tally) are what the race tracker reads, through the allocation-free ingest::lol::state_v2::kills_from_record (frame index and the two counts, no player block decoded — three folds read it per frame; the kills-at-minute family's fold reads the same two counts and judges their SUM (and, ONE of them for the side family — SC-10; the gold-lead family reads the ten players' total_gold summed per side instead, gold_from_record — SC-11) on the snapshot whose ordinal is the line's minute, settlement-contract SC-9); a line closes on the first snapshot whose count reaches it, so the settlement unit is the minute, never an event inside it.
  3. Deterministic. The fold is pure (reuses the v1 cumulative fold + copies the frame's integer economy), so two independent replays are byte-identical — the determinism gate on real data (verify::diff_runs, flows.md Flow 6), as cs2/dota2/lol-v1 carry.

6.16 Epoch log (root — the Allocation Layer's one root, allocation-market.md §5) #

One log per allocation epoch, one writer — the epoch gateway, an impure edge (⚠) that loads the operator's epoch document, records the operator's inclusions and the harness's verdicts, and tails the partner chain's FINAL blocks for stakes into the chain's vault (a partner feed: it owes data-conformance, both tiers, and security-review when a parser exists). Fsync-before-ack like every root (§5). Every record belongs to the epoch whose log it sits in (epoch_opened carries the document's hash as epoch_id and run_included inside its allocation_ref; a stake, a block or a verdict names no epoch — the log IS the epoch); the document itself is stored content-addressed beside the log (the model-plugin-layer §10 gate-details precedent) and is immutable from the first record on. Schema ids (§7 rule 4's ledger): 1_000_030 epoch_opened/v1, 1_000_031 run_included/v1, 1_000_032 block_final/v1, 1_000_033 stake_recorded/v1, 1_000_034 run_verdict/v1, 1_000_035 stake_withdrawn/v1, 1_000_043 stake_moved/v1, 1_000_081 carry_settled/v1 (a caps_rule v4 chain's, futarchy-system.md §7 E8) — owned by the futarchy crate (records::log), the enforcing layer, minted with the fold as their first declared consumer (rule 2), the fixture emitter producing them until the gateway produces them — d2-cycle, the epoch gateway's operator half (futarchy-system.md §7 "The cycle runner"), is the first producer of run_included and run_verdict rows beside the fixture emitter, and of the post-close block_final and desk_topup rows from the recorded chain tail chain/tail.cbor (futarchy-system.md §11 W13) until the chain reader (E2) exists; 1_000_068 run_included/v2, the shared-line inclusion row a caps_rule v2 document's epoch records (futarchy-system.md §4 B5 v3, §7 E3), owned by the same module (records::log), minted past the risk engine's 1_000_061..=1_000_067; 1_000_061 desk_topup/v1 is minted at gate G3 (risk-engine.md §11) in the same module, a ROOT record of the chains' DAG (ROOT_RECORDS, futarchy-system.md §6), the fixture emitter producing it until the chain reader (E2) produces it (its post-close rows meanwhile by d2-cycle from the recorded tail). Envelope convention: a record's replay_ts is the chain HEIGHT it records (epoch_opened carries the document's open_height — the layer's only clock is the chain's, futarchy-system.md §4), seq the gateway's running index, the sleeve 0. Body field ids are integers per record, stated in each row.

Event Body fields
epoch_opened/v1 {0 epoch_id, 1 system_hash, 2 document_len, 3 gateway_hash} — epoch_id (32 bytes — BLAKE3 of the document's canonical bytes), gateway_hash (32 bytes — the content hash of the gateway build that appended the row, informational; the fixture emitter writes the BLAKE3 of its label fixture:gateway), system_hash (32 bytes — the document's field 51, BLAKE3(snapshot ‖ chain ‖ risk_chain) over every field but the link 39, the hash 51, the chain fields 50/54 and the position fields 31/48, repeated on the wire: CLAUDE.md law 8(b)'s frozen wire field; futarchy-system.md §3 fact 2), document_len. Once per log, first, appended by the config loader edge (futarchy-system.md §7 E1); a second one is corrupted history
run_included/v1 {0 run_id, 1 manifest_hash, 2 allocation_ref {0 epoch_id, 1 allocation_offset}, 3 height} — refused at the edge, and corrupted history if recorded, at or after the window's deadline or naming a sleeve whose earlier included run has no verdict row yet (allocation-market.md §14; futarchy-system.md §4 B7) — run_id, manifest_hash (one value, two fields: run_id IS the frozen manifest's hash, state-layers §11 — a row whose two disagree halts the fold), allocation_ref {epoch_id, allocation_offset} (the same nested map the run manifest's field 14 carries), height (u64 — the latest final height recorded when the operator included the run — an EQUALITY the fold checks, a row stamped back or ahead halts, and the envelope's replay_ts must be that height; the window of allocation-market.md §9 places the run by it) — the operator includes a run whose frozen manifest cites this epoch's allocation record and carries its capacities as sleeve ceilings (allocation-market.md §8); refused at the edge otherwise
run_included/v2 (1_000_068, MINTED — records::log) run_included/v1's four fields plus the run's sublimits: {0 run_id, 1 manifest_hash, 2 allocation_ref {0 epoch_id, 1 allocation_offset}, 3 height, 4 sublimits[] — {0 sleeve (u32), 1 ceiling_mills (u64)}} — the row a caps_rule v2 or v3 document's epoch records in place of v1 (B5 v3 on the v2 chain, B5 v4 on the v3 chain — futarchy-system.md §4 B5, §9 C100; §7 E3): the sublimits are every bookmaker sleeve the run's frozen manifest names, each with that sleeve's ceiling_mills, so the log alone carries what the shared line sums; the body is closed (Unknown on another key, a sublimit the closed map {0, 1}); the decoder refuses a sublimit list not strictly ascending by sleeve id, an unsorted or a repeated sleeve (NotAscending on key 4), and an empty list (Identity); the four head fields are v1's; the edge's deadline refusal is v1's; v1's live-sleeve refusal is replaced by the line rule (E3 appends a v2 row only when the declared ceiling route's line holds at it, under B5 v4 also never while the pool is pending); that the sublimits agree with the manifest and that each pool's included sublimits sum within its line are the fold's and the START's checks, which know the document (futarchy-system.md §4 B7, §10; allocation-market.md §14)
block_final/v1 {0 height, 1 block_hash} — height, block_hash (32 bytes — the partner chain's, the genesis anchor's input) — every final block from the document's open_height through the first final above the grace end (close_height + window_blocks + verification_grace_blocks + 1) and, on a caps_rule v4 chain, on until the epoch's capital release (the last release row of its carried runs — futarchy-system.md §4 "The v4 chain"), where the unjudged abort is decided (futarchy-system.md §9 C37), heights consecutive with no gap (the opening height is what makes "no gap" checkable from the log itself), so the close at close_height, the deadline and the grace end are observed from the log even when no stake or inclusion arrives after them; a chain reporting a different hash for a recorded height is a feed violation, never a rewrite (allocation-market.md §5, the reader's four feed rules)
stake_recorded/v1 {0 participant, 1 pool, 2 amount, 3 tx_id, 4 height} — participant (chain account bytes), pool (u32, the document's index — 1 = cash), amount (u64, integer money_unit), tx_id (32 bytes), height — a listed participant's stake on a listed pool, at least min_stake_units, at a height below the close; the seeder's seeds are ordinary rows of this kind and must be the FIRST row on every pool (allocation-market.md §5)
run_verdict/v1 {0 run_id, 1 verdict (u64: 0 verified, 1 failed — any other code refused), 2 report_hash, 3 height} — refused at the edge, and corrupted history if recorded, after the window's terminal record (futarchy-system.md §4 B7), except, on a caps_rule v4 chain, a carried run's row in its accepting epoch's log until its Verified row or its carry_settled/v1 row (futarchy-system.md §4 "The v4 chain", window_rule v5) — run_id, verdict (verified or failed), report_hash (the determinism harness's verified-run record, state-layers §11 — CONFIG default: verify::report_hash, BLAKE3 over the domain tag fullhouse:report_hash:v1 then the run's digest table in file-name order, each len(name) (u64 BE) ‖ name ‖ records (u64 BE) ‖ end_hash; B6 recomputes a run's streams' digests and the table's hash and halts on a disagreement with this row; the harness's own record format replaces it when specified), height — appended by the gateway when the harness's verdict for an included run arrives, so the window's membership (allocation-market.md §9) is a function of the epoch log, never of harness timing. Re-checked by the fold like run_included: the run has a run_included row before it, one verdict per run — under window_rule v4 (B7 v5, the v3 chain's) a second row for a run only when every earlier row of it is failed (the rebuilt record's verdict: the same manifest re-folded over the same root logs, state-layers §11), a row after a verified one refused; under v2 and v3 any second row refused (futarchy-system.md §9 C99) —, height EQUAL to the latest final height recorded before the row, the envelope's replay_ts its height — each miss a typed halt (allocation-market.md §14)
stake_withdrawn/v1 the same body ids as stake_recorded/v1 {0 participant, 1 pool, 2 amount, 3 tx_id, 4 height} — only under the document's stake_mobility (56) withdrawals; the seeder's position on the pool never below seed_units[i] (C12); 0 ≤ amount ≤ r_ij, the pool stays positive and the participant's net position left is 0 or at least min_stake_units (the 2 October text's withdrawal bounds, "Changing bets and withdrawal" — the 19 October note refuses withdrawals, so a v2 document refuses the withdrawals code, C12; the minimum on positions is min_stake_units, C24, the CEO's ruling); never at or after the close
stake_moved/v1 (1_000_043) {0 participant (bytes), 1 from_pool (u32), 2 to_pool (u32), 3 amount (u64), 4 tx_id (32 bytes), 5 height (u64)} — ONE atomic reallocation under the document's stake_mobility moves or withdrawals: from_pool ≠ to_pool, both listed; on the source the withdrawal bounds (amount ≤ r_ij, the pool stays positive, the position left 0 or at least min_stake_units); on the destination the stake-row rule (amount ≥ min_stake_units, onto a pool that already has its first row — a move never seeds a pool); the seeder's position on a pool never below seed_units[i]; the tx_id unique across the three row kinds; never at or after the close; a position born by a move takes the move's offset as its first row (allocation-market.md §4, §5; futarchy-system.md §9 C12)
desk_topup/v1 (1_000_061, minted at gate G3 — records::log; produced by the chain reader E2, the fixture emitter until E2 exists) {0 pool (u32 — the document's index of a bookmaker pool), 1 amount (u64 capacity mills, above 0), 2 tx_id (32 bytes), 3 height (u64)} — a desk's own-capital deposit from a final block, after that block's block_final row like a stake row, at heights at or above M through the window's terminal — h* at a close, g (the first final at or above the grace end, futarchy-system.md §9 C37) at an abort, the abort DECIDED only by the final above g, its tx_id at most once over the whole log — one set across the close walk and the post-close walk (risk-engine.md §4, §11)
carry_settled/v1 (1_000_081, minted with the fold as its first declared consumer, B5 v5 and B7 v6 — records::log; produced by the vault edge E8, the fixture emitter until E8 exists) {0 run_id (32 bytes), 1 released (u64 capacity mills), 2 consumed (u64 capacity mills)} — the vault side's settlement of the matches of a CARRIED run of a caps_rule v4 epoch that has no Verified row (futarchy-system.md §4 "The v4 chain", §7 E8; CONFIG C113): recorded in the run's accepting epoch's log, the envelope's replay_ts its height, EQUAL to the latest final height recorded before it, as a verdict row's; released + consumed equal to the run's line (the sum of its run_included/v2 sublimits); the one row that moves such a line out of L(h), its released amount (field 1) joining the then-running epoch's pot (futarchy-system.md §4 "The v4 chain") — except a line released in an INSIDE then-running epoch's turn that the epoch does not cover (an excess line, one that found no room at the window start into that turn, or one released in the turn's gap before that window start), or released in the turn's gap before the then-running epoch's window start, in the turn of an epoch aborted before its allocation, or at a height in no opened epoch's turn (under an OUTSIDE then-running epoch every release in its turn at or after its window start enters its R(h)), whose released amount joins no running pot (futarchy-system.md §9 C106, C124) —, and its consumed amount the vault side's loss, in the covering epoch's C(h) under INSIDE, the holding epoch's R(h) under OUTSIDE reading the released amount alone; refused at the edge, and corrupted history if recorded, below the run's accepting epoch's deadline (the run not yet carried), for a run that is not a carried run of the log's epoch, for a run with a Verified row, a second time for one run, or with amounts not summing to its line, and a run_verdict after it refused (allocation-market.md §14)

Refused at the edge, never appended: a stake, withdrawal or move from an unlisted participant, on an unlisted pool, a stake or move below the minimum, any of the three below open_height or at or past the close height, ahead of the latest recorded final block (a block's block_final row precedes its stake, withdrawal and move rows — the fourth feed rule), or repeating a tx_id already in the epoch's log (every tx_id at most once), and on a caps_rule v4 chain a stake whose tx_id is a stake row's in the log of an earlier epoch of the lineage not yet capital-released (StakeNotFresh, futarchy-system.md §4 B1 v3); a withdrawal or move the document's stake_mobility forbids, or that would zero a pool; a move onto the same pool, beyond the position, leaving dust, onto a pool before its first row, or taking the seeder's position below its seed (a withdrawal likewise); an inclusion whose manifest disagrees with the allocation; a NON-SEED first row on a pool ("an earlier non-seed stake is refused"). A recorded one is corrupted history and halts the fold (allocation-market.md §14) — distinct from the three ABORTS of the close (allocation-market.md §6): a pool with no row at all, or whose seeder's first row is below seed_units[i], aborts the epoch as pool_unseeded; a pool at R_i = 0 at the close as pool_unfunded; a close at which no position above zero is held by anyone but the seeder as no_votes.

6.17 Allocation stream (derived by the Allocation Fold, allocation-market.md §5) #

One derived stream per epoch, recomputable from the epoch log, the document and the included runs' sealed streams. Rationals ride as {0: sign, 1: num, 2: den} (sign the unsigned 0 or 1, zero the empty num with sign 0; allocation-market.md §5) with big-endian magnitudes, no leading zero byte, den ≥ 1 and the pair in lowest terms — one encoding per value, as §3 rule 5 requires (§3 rule 4's "no bignums" names the CBOR bignum tags 2 and 3; a declared byte-string field carrying an arbitrary-precision integer is not a tag, and byte strings are among the allowed types); everything else is an integer. Ids: the first six (the fold B0–B4 — their producer): 1_000_036 document_checked/v1, 1_000_037 book_closed/v1, 1_000_038 raw_share/v1, 1_000_039 capped_share/v1, 1_000_040 allocation/v1, 1_000_041 epoch_aborted/v1 (B1's other output) — owned by the futarchy crate (records::stream; epoch_aborted/v1 — records::aborted); 1_000_042 return_observed/v1 (B6 — records::returns); 1_000_044 window_closed/v1 (B7 — records::window); 1_000_045 utilization/v1 (B8 — records::utilization); 1_000_046 winsorized/v1 (B9 — records::winsorized); 1_000_047 estimate/v1 (B10 — records::estimate); 1_000_048 feasible_set/v1 (B11 — records::feasible_set); 1_000_049 settlement/v1 (B12 — records::settlement, minted with its first declared consumer, the certificate's verifier; its producer the solver); 1_000_050 pool_liability/v1 (B13 — records::liability); 1_000_051 stake_payout/v1 (B14 — records::payout; its first producer extending §7's reserved region to 1_000_030..=1_000_052, epoch_settled reserved at 1_000_052); 1_000_052 epoch_settled/v1 (B15 — records::settled; the v1 region's last id — every record of the v1 chain is minted); 1_000_055 capped_share/v2 (B3 v2 — records::shares_v2, task 143 step 3b, the first producer extending the region toward ..=1_000_060 per C61); 1_000_056 allocation/v2 (B4 v2 — records::shares_v2); 1_000_053 balance_series/v1 (B6b — records::series, task 143 step 3c); 1_000_057 utilization/v2 (B8 v3 — records::utilization_v2, task 143 step 3d); 1_000_058 winsorized/v2 (B9 v2 — records::winsorized_v2, task 143 step 3e); 1_000_059 feasible_set/v2 (B11 v2 — records::feasible_set_v2, task 143 step 3g); 1_000_060 settlement/v2 (B12 v2 — records::settlement_v2, task 143 step 3h); 1_000_054 participation_yield/v1 (B14b — records::participation_yield, task 143 step 3i, minted by B14b — the 31st id, the C61 region 1_000_030..=1_000_060 COMPLETE); 1_000_069 balance_series/v2 (B6b v2) and 1_000_070 settlement/v3 (B12 v3) — the v3 chain's two records, past run_included/v2 1_000_068, each minted by its first producer; 1_000_072 epoch_aborted/v2 (B1 v2 and B7 v5 — records::aborted, the v3 chain's abort record, minted by its first producer; 1_000_071 unminted). Envelope convention: a derived record carries the INPUT record's replay_ts (the closing block_final's height for B1–B4's records, epoch_opened's for document_checked) and the stream's own running seq from 0 (the exchange engine's stamp precedent) — so the stream's layout is document_checked 0, book_closed 1, raw_share 2, capped_share 3, allocation 4; B6's rows follow at 5 + j — j the row's place among the run_verdict{Verified} rows in log order, from 0 (a Failed verdict takes no offset) — each stamped its verdict's height; window_closed after them at the window's h*, and the window-derived records — utilization, winsorized, estimate, feasible_set (B8, B9, B10, B11) — carry window_closed's h* at its offset + 1, + 2, + 3 and + 4 in that order, and settlement (B12) at

  • 5 — the solver's stamp; pool_liability at window_seq + 6 — B13's stamp (C48); the ℓ-th stake_payout (0-based, B1's first-row order; m positions) at window_seq + 7 + ℓ — B14's stamps (C49), the last checked before any is written; epoch_settled at + 7 + m — B15's stamp (C50); epoch_aborted at 1 (B1, the close's height) or at window_closed's place (B7, its terminal height) — the stream's last record in either case. Under caps_rule v2 the layout is allocation-market.md §5's v2 layout, held in ONE table with the v1 one (futarchy::chain::layout, task 143 step 3j-1 — every block's stamp and the registry's walker read it): the close four book_closed, raw_share, capped_share/v2, allocation/v2 at 1–4 (the allocation the close's last record under both versions, so a manifest's allocation_ref names offset 4 in both); B6's rows at 5 + j as under v1; then the EIGHT window records on h* — window_closed, balance_series + 1, utilization/v2 + 2, winsorized/v2 + 3, estimate + 4, feasible_set/v2 + 5, settlement/v2 + 6, pool_liability + 7 — the m payouts at + 8 + ℓ, the Y participation_yield rows at + 8 + m + j and epoch_settled at + 8 + m + Y — the places the composition's v2 branch stamps (fold_to_settlement, task 143 step 3j-2: each block from the table it is handed, B14b's first row at the table's yield_first(m) after the window stamp). The epoch registry reads a stored stream against the layout of its document's version as a PREFIX AUTOMATON (registry::read): after document_checked B1's epoch_aborted or the close four, then return_observed, then B7's epoch_aborted or the window records in order, then stake_payout, under v2 participation_yield*, then epoch_settled — a yield row in a v1 stream, a payout after a yield row, a v2 window whose second record is not balance_series are out of shape. Under caps_rule v3 the layout is allocation-market.md §5's v3 layout (Layout::V3): the close four and B6's rows as under v2; then the SIX window records on h* — window_closed, balance_series/v2 + 1, utilization/v2 + 2, feasible_set/v2 + 3, settlement/v3 + 4, pool_liability + 5 — the m payouts at + 6 + ℓ, the Y participation_yield rows at + 6 + m + j and epoch_settled at + 6 + m + Y; no winsorized and no estimate; the registry reads a v3 stream against this layout, and under B7 v4 and v5 a window below the match floor ends in window_closed too, never in epoch_aborted{too_few_matches}.
Event Body fields
document_checked/v1 {0 epoch_id, 1 system_hash, 2 field_count, 3 chain_len} — epoch_id, system_hash, field_count (the value keys the document carries — 34 under payout_cap_rule v1, 35 under its v2; on a caps_rule v2 document 38 or 39: four keys retired, eight values added; on a caps_rule v3 document 45 or 46: v2's keys less 10, 13, 15 and 16, plus 67, 68, 70–77 and 78 — the risk chain naming R7 (78) and R5/R6 (67, 68) — and 46 or 47 with the optional field 79 (the risk chain naming R2 v2) (risk-engine.md §7's key-set rule); on a caps_rule v4 document the v3 count plus carry_scoring_rule (80) and carry_budget_rule (81), both required — 47 or 48, and 48 or 49 with field 79 (allocation-market.md §3 "The v4 document"); the two rule identities 65 and 66 are chain rows, not keys — the count the key-set rule's, carried by the code's key_count, the v3 count with field 78 at gate G8 and fields 67 and 68 at gate G3; the row for a v2 document written by B0 v2 since its declaration), chain_len — the fold's first row: the stored document re-passes every check of allocation-market.md §3 and both hashes recompute (futarchy-system.md §4 B0)
book_closed/v1 {0 pool_totals[], 1 total, 2 stake_count, 3 last_admitted_offset} — pool_totals[] (u64 per pool — R_i), total R (Σ pool_totals — every sum checked, a wrap corrupted history), stake_count (stake rows only), last_admitted_offset (the last stake, withdrawal OR move row admitted) — on the first block_final at or above the close height, every pool seeded and positive and at least one vote (a position above zero not the seeder's)
raw_share/v1 {0 weights[]} — weights[] (rationals — w^M_i = R_i / R) — after book_closed (futarchy-system.md §4 B2)
capped_share/v1 {0 weights[], 1 binding_set[]} — weights[] (rationals — w^I = Γ_C(w^M)), binding_set[] (u32 pool indices, ascending — the pools whose caps bind) — after raw_share (allocation-market.md §7; futarchy-system.md §4 B3)
allocation/v1 {0 epoch_id, 1 capacities[]} — epoch_id, capacities[] (u64 mills per pool, Σ = vault_capacity_mills) — one rounding to mills with every pool floored and the whole leftover to cash (allocation-market.md §7; futarchy-system.md §4 B4); the record every manifest of the window cites by offset (§8)
return_observed/v1 (1_000_042) {0 run_id (32 bytes), 1 match_ids[] (bytes each — the run's match identity: the root match log's stream id from the segments' lineage, one per run — a reading of returns_rule v1, futarchy-system.md §9 C33), 2 rows[] (one closed map per NON-CASH pool in ascending pool index: {0 pool (u32), 1 kind (0 observed, 1 missing), and under kind 0 only: 2 pnl (signed mills, CBOR major 0/1), 3 capital (u64 mills — the run's peak, sampled at each replay_ts boundary, of Σ_markets max(posted_m, 0) + Σ_markets reservation_m; > 0 on an observed row or the decoder refuses), 4 drawn (u64 mills — the same peak of Σ_markets max(posted_m, 0); ≤ capital or the decoder refuses)}), 3 collateral_ticks (u128 as minimal big-endian bytes, the empty string for 0 — informational)} — one per included run whose run_verdict{Verified} row is in the epoch log, in the ORDER of those verdict rows (a Failed verdict yields none), each stamped with its verdict row's replay_ts (the height) and the stream's running seq from 5 (the layout after allocation) — so the stream is a function of the epoch log and the runs' sealed streams alone (allocation-market.md §9; futarchy-system.md §4 B6)
window_closed/v1 (1_000_044) {0 run_ids[] (32 bytes each — the runs in D_T, inclusion order), 1 failed_run_ids[] (32 bytes each, inclusion order), 2 k (u64 — the size of D_T, checked equal to run_ids' length at decode), 3 distinct_matches (u64, over D_T's return_observed match ids only), 4 deadline_height (u64 — M + window_blocks)} — stamped h*: the smallest final height at or above the deadline at which every included run has its run_verdict row of height ≤ h* (the height reading, futarchy-system.md §9 C37), and no later than the grace end (allocation-market.md §9, the fixed-period window; futarchy-system.md §4 B7); its offset follows every return_observed row; below min_distinct_matches, or with an included run still unjudged at the grace end (decided at the first final above it — C37), epoch_aborted instead and no window_closed; under window_rule v4 (B7 v5) the close needs a verified row per included run, so failed_run_ids is empty at every close (the recovery read from the epoch log's verdict rows), and an included run whose latest row at the grace end is failed aborts as run_unrecovered (futarchy-system.md §9 C99)
utilization/v1 (1_000_045) {0 rows[]} — a row a closed map by kind {0 pool (u32), 1 kind (u64: 0 PEAK, 1 NONE — any other refused), 2 u (rational, the shares' wire form — PEAK only: the maximum over D_T's runs of the run's observed capital / V, above zero, no upper bound: measured even above 1, C39), 3 peak_run_id (32 bytes — PEAK only: the first run in D_T order that set it)}, one row per NON-CASH pool ascending (NotAscending otherwise); a NONE row — a pool no run of D_T observed — carries neither 2 nor 3 (the kind convention of return_observed); stamped window_closed's h* at the offset after it (futarchy-system.md §4 B8; §9 C39; allocation-market.md §5)
winsorized/v1 (1_000_046) {0 matrix_hash (32 bytes — BLAKE3 over C41's preimage of the K × N winsorized matrix: fh:winsorized-matrix/v1, K, N as big-endian u64, the entries row-major in the §6.17 rational form, futarchy-system.md §9 C41), 1 k (u64 — K, the matrix's rows), 2 rows[] — a row a closed map {0 pool (u32), 1 observed_runs (u64 — T_i, the runs of D_T where the pool was observed; cash's is K, C40)}, one per document pool ascending, cash first (NotAscending otherwise; an observed_runs above k refused)} — stamped window_closed's h* at the offset after utilization (futarchy-system.md §4 B9; allocation-market.md §5, §10)
estimate/v1 (1_000_047; under v2 written by B10 v2 unchanged in shape — task 143 step 3f) {0 matrix_hash (32 bytes — the winsorized record's hash re-stated for B12, futarchy-system.md §9 C41; under v2 winsorized/v2's matrix_hash, the winsorized one of its two), 1 rows[] — a row a closed map {0 pool (u32), 1 mu_hat (the §6.17 rational — μ̂_i, either sign; cash's cash_return verbatim (v1/v2)), 2 observed_runs (u64 — T_i; cash's K, C40; under v2 the pool's recorded snapshots — K surviving, its last snapshot wiped-out, K with no series, futarchy-system.md §9 C56 — the field name wire, its meaning the version's)}, one per document pool ascending, cash first (NotAscending otherwise)} OR {0 matrix_hash, 2 exhausted (u32 — the rational_bits_max a sum, mean, δ or μ̂ would exceed; B12 reads it as a budget exhaustion, futarchy-system.md §9 C44)} — exactly one of the keys 1 and 2, the map closed either way; the objective note's Θ; stamped window_closed's h* at the offset after winsorized (under v2 after winsorized/v2; allocation-market.md §5, §10; futarchy-system.md §4 B10); no v3 block writes it (B10 is absent from the v3 chain)
feasible_set/v1 (1_000_048) {0 rows[] — a row a closed map {0 pool (u32), 1 upper (the §6.17 rational — min(w̄_i, cap^util_i), cash w̄_1), 2 lower (the §6.17 rational — b̲ on cash, 0 elsewhere)}, one per document pool ascending, cash first (NotAscending otherwise; at least one row; lower ≤ upper and both within [0, 1] — Identity otherwise)} — the bounds of F_T^util; stamped window_closed's h* at the offset after estimate (allocation-market.md §5, §10; futarchy-system.md §4 B11)
settlement/v1 (1_000_049) {0 weights[] (the §6.17 rationals — ŵ*, one per document pool ascending, cash first, each within [0, 1]; at least one — Identity otherwise), 1 zeta (the §6.17 rational, either sign — recorded because the certificate's stationarity conditions are over (w, ζ, z) and cannot be re-checked without it), 2 active_set[] (u32 — ascending, distinct indices of the constraint ENUMERATION of futarchy-system.md §9 C46: [0, N) lower bounds, [N, 2N) upper bounds, [2N, 2N + K) z_k ≥ 0, [2N + K, 2N + 2K) the tail constraints; NotAscending otherwise; the range is the verifier's), 3 multipliers[] (the §6.17 rationals — one per active index, each ≥ 0; Identity otherwise), 4 steps_used (u64), 5 bits_max_used (u64)} OR {0 weights[] (w^M verbatim), 6 exhausted (u32: 0 steps, 1 bits — any other refused (code 2 caps, Γ_C v2's budget, is settlement/v2's — futarchy-system.md §9 C46, C72))} — exactly one outcome, the map closed either way; the presence of key 6 is fallback: true; the exact-rational argmax over F_T^util with its KKT certificate (the equality's multiplier ν not recorded — derived by the verifier), or ŵ* := w^M (allocation-market.md §11); stamped window_closed's h* at the offset after feasible_set (the solver's stamp; futarchy-system.md §4 B12)
pool_liability/v1 (1_000_050) {0 v[] (the §6.17 rationals — ŵ*_i / w^M_i on the RAW share, one per document pool, cash first), 1 v_capped[] (v'_i — v under payout_cap_rule v1), 2 liability[] ((1 − τ_pool) R_i v'_i, EXACT — the reservation's u64 amended by the first producer: nothing is rounded at B13, B14 floors the payouts and B15 the tax and the residue, futarchy-system.md §9 C48), 3 tax (τ_pool R, exact), 4 cap_residue ((1 − τ_pool) Σ_i R_i (v_i − v'_i), exact, 0 under v1)} — a closed map; the decoder holds the three arrays non-empty and of one length, every v ≥ 0, v_capped ≥ 0, v_capped ≤ v, every liability ≥ 0, tax ≥ 0, cap_residue ≥ 0; stamped (h*, window_seq + 6) after settlement (allocation-market.md §12; futarchy-system.md §4 B13, §9 C48)
stake_payout/v1 (1_000_051) {0 participant (bytes), 1 pool (u32 — the document's wire index), 2 amount (u64 money_unit)} — the REFUND's three keys through ONE codec home (epoch_aborted's refund rows: a payout IS a position priced); one per NET POSITION r_ij > 0 at the close (stakes net of withdrawals and moves, per participant per pool — B1's list, never re-folded), in the order of each position's first row of any kind (a position born by a move at the move's offset; one grown or drained and re-credited in its slot); each amount ⌊r_ij P_i / R_i⌋ FLOORED ONCE from its pool's EXACT liability (= ⌊r_ij (1 − τ_pool) v'_i⌋, rounding_rule v1 — the economist's), never more than the exact share, the dropped fractions B15's one charge; a closed map; the decoder holds the shape and pool within u32 only — amount = 0 is legal (a share that floors to nothing); stamped (h*, window_seq + 7 + ℓ), ℓ the position's 0-based index in that order; v3: +6 + ℓ, allocation-market.md §5's v3 layout (allocation-market.md §12; futarchy-system.md §4 B14, §9 C49)
epoch_settled/v1 (1_000_052) {0 tax_amount (u64 — ⌊τ_pool R⌋), 1 cap_residue (u64, floored — 0 under payout_cap_rule v1), 2 rounding_charge (u64 — R − payouts_total − tax_amount − cap_residue), 3 payouts_total (u64 — Σ of B14's payouts)} — the tax and the residue floored ONCE here with their fractions in the charge (futarchy-system.md §9 C48 (b)); payouts_total + tax_amount + cap_residue + rounding_charge = R holds exactly by construction, the charge formed as the difference; the fold HOLDS the stored liabilities' identities (incl. T_tax = τ_pool R, hence — with conservation and the net identity held — cap_residue = 0), the payouts against the pools' liabilities, and the charge's range — at most one unit per payout row (rounding_charge ≤ m; its detection power limited, allocation-market.md §12 — the chain-level tap payouts_reproduce over the composition is the condition): a charge above it, a non-zero residue or a payout above its pool's due is corrupted history; a closed map, the decoder holding the shape and the four re-adding within u64; no account — tax_recipient is the document's; stamped (h*, window_seq + 7 + m) after the m payouts; v3: +6 + m + Y, allocation-market.md §5's v3 layout (allocation-market.md §12, §14; futarchy-system.md §4 B15, §9 C50)
epoch_aborted/v1 {0 reason {0 kind (u64: 0 pool_unseeded, 1 pool_unfunded, 2 too_few_matches, 3 run_unjudged, 4 no_votes — any other code refused), then per kind 1 pool (u32) ¦ 1 distinct, 2 floor (u64) ¦ 1 run_id (32 bytes) ¦ nothing}, 1 refunds[] of {0 participant, 1 pool, 2 amount}} — reason (pool_unseeded{pool}, pool_unfunded{pool} or no_votes — no position above zero but the seeder's, R20 / C8's state reading — at the close; too_few_matches{distinct, floor} or run_unjudged{run_id} at the window's close), refunds[] {participant, pool, amount} — every net position returned in full, in the order of each position's first row of any kind; the reason names the LOWEST-INDEX failing pool — kind 2 too_few_matches is written by B7 v2 and v3; B7 v4 writes no such abort, nor does B7 v5 (the floor is a reported count); the v1 and v2 chains' record (B1 v1, B7 v2 and v3; B7 v4, declared and assembled by no chain, writes it too)
epoch_aborted/v2 (1_000_072; B1 v2 and B7 v5, the v3 chain's one abort record) epoch_aborted/v1's body with kind 5 admitted: {0 reason {0 kind (u64: 0 pool_unseeded, 1 pool_unfunded, 2 too_few_matches, 3 run_unjudged, 4 no_votes, 5 run_unrecovered — any other code refused), then per kind 1 pool (u32) ¦ 1 distinct, 2 floor (u64) ¦ 1 run_id (32 bytes) for kinds 3 and 5 ¦ nothing}, 1 refunds[] of {0 participant, 1 pool, 2 amount}} — kind 5 run_unrecovered{run_id}: at the grace end the first included run in inclusion order without a verified row, its latest row failed (futarchy-system.md §9 C99); written on the v3 chain by B1 v2 (kinds 0, 1, 4) and B7 v5 (kinds 3, 5); kind 2 written by no v3 block; the refunds as v1's; the v1 decoder refuses this id and kind 5
balance_series/v1 (1_000_053, MINTED 2026-10-20 — task 143 step 3c, records::series; the 19 October note's snapshots, derived from the runs; allocation-market.md §9) {0 rows[] — a row a closed map {0 pool (u32), 1 snapshot (u64, k), 2 balance B (rational, capacity mills — no sign bound: at most 0 on a wipe-out row), 3 peak_used U^peak (rational, capacity mills, at least 0), 4 free_own, 5 free_credit (rationals, capacity mills, each at least 0), 6 utilisation η (rational, in [0, 1]), 7 balance_return R^bal (rational — no sign bound: at most −1 on a wipe-out row)}, one row per bookmaker pool per snapshot k = 0 … K — the opening snapshot at k = 0 (U^peak, η and R^bal pinned at 0 there, recorded values and not observations — futarchy-system.md §9 C59; the decoder refuses an opening row carrying any other), interval k's close at k ≥ 1 — in document order then snapshot order, the snapshots contiguous from 0 within a pool; a pool with no series writes none and the opening row's B is positive; a wipe-out row (B ≤ 0 at k ≥ 1) carries η = 1 and is its pool's last row — the decoder refuses a row after it, an η ≠ 1 on it and an opening row with B ≤ 0 (C56)} — after window_closed (futarchy-system.md §4 B6b)
participation_yield/v1 (1_000_054, MINTED 2026-10-20 — task 143 step 3i, records::participation_yield; minted by B14b, the C61 region complete) {0 participant (bytes, non-empty), 1 amount (u64 money_unit, above 0)} — a closed and total map; one per participant with Y_j = ⌊y H_j / Σ_m H_m⌋ > 0 (y = participation_reward_units, H_j the duration-weighted substake sum over M − open_height — futarchy-system.md §9 C54, C64), the seeder among them (as the note states — rule 66's identity, not a config field; futarchy-system.md §9 C64), in the order of the participant's first appearance in B1's position list; stamped h* at first_seq + j after the last stake_payout and before epoch_settled (allocation-market.md §5's +8 + m + j — first_seq the composition's, window_seq + 8 + m; v3: +6 + m + j, allocation-market.md §5's v3 layout); with y = 0 (C64's default before R49's value) or Σ H = 0 no row. Identities the fold holds: Σ_j Y_j ≤ y with the remainder y − Σ_j Y_j unpaid and an integer below the number of participants with H_j > 0; every row's participant a holder of a position in B1's list (futarchy-system.md §4 B14b)
capped_share/v2 (1_000_055, MINTED 2026-10-20 — task 143 step 3b) {0 weights[] (rationals — the adjusted weights before the precision step; the weights at the stop on an exhausted record, off the simplex), 1 bounds[] (rationals — b_i per pool, cash 1; on an exhausted record the bounds as they stand when the overrun was found, a bound not yet formed 0 — C72), 2 binding_set[] (u32 pool indices whose individual limit bound in step 1, ascending), 3 protected_groups[] (arrays of u32 members ascending, in processing order), 4 exhausted (u64 1, present only when Γ_C's budget ran out — its passes against step_budget or any step past rational_bits_max, the bounds' own formation included, C72)} — Γ_C v2's record (allocation-market.md §7)
allocation/v2 (1_000_056, MINTED 2026-10-20 — task 143 step 3b) {0 epoch_id, 1 capacities[] (u64 mills per pool, Σ = vault_capacity_mills), 2 outcome (u64: 0 computed, 1 fallback — the all-cash allocation after a failed verification or an exhausted budget)}
utilization/v2 (1_000_057, MINTED 2026-10-20 — task 143 step 3d, records::utilization_v2) {0 rows[] — a row a closed map {0 pool (u32), 1 efficiency e^util (rational, at least 0 — max_k η_{i,k} / ρ over the pool's recorded snapshots; no upper bound at the decoder — at most 1/ρ since η ≤ 1; B11 v2 reads any value at or above 1 as no utilisation bound, so a value above 1/ρ changes no bound and no upper check is owed — task 143 step 3g), 2 peak_used (rational, capacity MILLS, at least 0 — max_k U^peak_{i,k}, the successor's u^prev, futarchy-system.md §9 C55)}, one row per NON-CASH pool, pools strictly ascending; a pool with no series writes (0, 0) (C56)} — after balance_series (futarchy-system.md §4 B8 v3)
winsorized/v2 (1_000_058, MINTED 2026-10-20 — task 143 step 3e, records::winsorized_v2) {0 matrix_hash (32 bytes — the winsorized K × N matrix over C41's preimage, fh:winsorized-matrix/v1), 1 raw_hash (32 bytes — the raw series matrix over the same preimage form under fh:raw-series-matrix/v1, retained), 2 k (u64 — the interval count K = window_blocks / snapshot_blocks, the document's even where no pool has a row at K), 3 thresholds[] (one rational per document pool in document order, cash first — q95_i, each at least 0, no upper bound; the array non-empty with its first entry, cash's, 0 — NO threshold, recorded as 0 and never applied (C40); the decoder refuses otherwise; the count against the document is B10 v2's shape check)} — after utilization (futarchy-system.md §4 B9 v2); T_i is not carried (B10 v2 derives it from the series, C56); no v3 block writes it (B9 is absent from the v3 chain)
feasible_set/v2 (1_000_059, MINTED 2026-10-20 — task 143 step 3g, records::feasible_set_v2) {0 rows[] — a row a closed map {0 pool (u32), 1 upper (rational), 2 lower (rational)} as feasible_set/v1's — one per document pool, cash first, ascending by pool (NotAscending otherwise), at least one, both bounds within [0, 1] and lower ≤ upper (the decoder refuses otherwise); 1 groups[] — a closed map {0 size (u32), 1 cap (rational)}, sizes strictly ascending and at least 2, each cap within (0, 1] (C70), the document's cap_groups with size ≤ N only (N the non-cash pool count — the note's "skip a group size greater than N" ("Group limits", the redistribution procedure), and under "Constraints on implementation" item 2 the caps range over S ⊆ {1,…,N}, so a size above N binds nothing; B11 v2 writes the bound, the decoder cannot know the document), an EMPTY list valid} — B11 v2's record (futarchy-system.md §4 B11 v2), stamped window_closed's h* at +5 after it (allocation-market.md §5)
settlement/v2 (1_000_060, MINTED — B12 v2, records::settlement_v2, task 143 step 3h) settlement/v1's body with the active set's group rows added: {0 weights[], 1 zeta, 2 active_set[], 3 multipliers[], 4 steps_used, 5 bits_max_used, 7 group_rows[] — {0 size (u32), 1 members[] (u32), 2 multiplier (rational)}} OR {0 weights[], 6 exhausted}, closed and total (allocation-market.md §11, futarchy-system.md §9 C46, C60), stamped window_closed's h* at +6 after it (allocation-market.md §5); the decoder holds settlement/v1's identities (exactly one outcome, the weights non-empty and each within [0, 1], the active indices ascending and distinct, one multiplier per index, each ≥ 0) and the GROUP ROWS' (CONFIG C46): key 7 present with the certificate (an empty list valid — MissingField when absent), a row the closed map {0, 1, 2} (Unknown otherwise), size ≥ 2, members pool POSITIONS in document order ascending and distinct (NotAscending on key 1), every member ≥ 1 (cash is position 0, never a member), as many members as the size, the multiplier ≥ 0, the rows ascending and distinct by (size, members) (Identity otherwise); its exhausted admits 0 steps, 1 bits and 2 caps — Γ_C v2's budget, read first (futarchy-system.md §9 C46, C72) — while settlement/v1's decoder keeps refusing 2 (UnknownCode); a size's listing and a member below the pool count are the verifier's (verify_settlement_v2), which knows the document
balance_series/v2 (1_000_069; B6b v2, the v3 chain; allocation-market.md §9 "The three return series") {0 rows[] — a row balance_series/v1's closed map with key 7 balance_return the daily return on B̃ (exactly −1 on a wipe-out row) and key 8 weekly_return (rational — B̃_k / B̃_{k−h_w} − 1), present iff the row's snapshot k ≥ h_w; 1 monthly[] — a closed map {0 pool (u32), 1 monthly_return (rational — B̃_K / B_0 − 1)}, one per pool with a series, pools ascending} — the rows under balance_series/v1's identities (the opening row's zeros, the contiguity, the wipe-out row last with η = 1); every return at least −1 (B̃ ≥ 0, a ratio with a zero opening 0 — futarchy-system.md §9 C95); key 8's presence held to the document's weekly_horizon_snapshots (73) by the series' document-side checks, the reader that holds the document; the monthly list naming exactly the pools that have rows — stamped window_closed's h* at +1 after it (allocation-market.md §5's v3 layout; futarchy-system.md §4 B6b)
settlement/v3 (1_000_070; B12 v3, the v3 chain; allocation-market.md §11) settlement/v2's body with the one zeta replaced by a list: {0 weights[], 1 zetas[] (the §6.17 rationals, either sign — one ζ_t per CVaR term, daily then weekly), 2 active_set[] (indices of the enumeration v3, futarchy-system.md §9 C46), 3 multipliers[], 4 steps_used, 5 bits_max_used, 7 group_rows[]} OR {0 weights[], 6 exhausted}, closed and total, stamped window_closed's h* at +4 after it (allocation-market.md §5's v3 layout); the decoder holds settlement/v2's identities — exactly one outcome, the weights non-empty and each within [0, 1], the active indices ascending and distinct, one multiplier per index, each ≥ 0, the group rows' identities — and zetas exactly two entries, the number of terms on v3; its exhausted admits 0 steps, 1 bits, 2 caps and 3 sparse (a window below min_distinct_matches, futarchy-system.md §9 C93), while settlement/v1 and settlement/v2 keep refusing 3 (UnknownCode); the sparse reason's condition, the index range and the KKT rows per term are the verifier's, which knows the window and the document
margin_called/v1 (1_000_062, minted at gate G3 — the desk-period chain) {0 pool (u32 — the document's wire index of a bookmaker pool; a cash pool corrupted history), 1 loss (u64 mills), 2 base (u64 mills — the pool's own capital, own_capital at the pool's position)} — R5's record at a return_observed row (risk-engine.md §4, §11)
margin_cleared/v1 (1_000_063, minted at gate G3) {0 pool (u32), 1 loss (u64 mills)} — R5's record at the row that brings the loss under the trigger, a desk_topup or a return_observed row (risk-engine.md §4, §11)
liquidated/v1 (1_000_064, minted at gate G3) {0 pool (u32), 1 loss (u64 mills), 2 base (u64 mills)} — R6's record (risk-engine.md §4, §11); under R6 v1 it triggers the cross-run stop on the desk's other unsealed runs, carried by their control roots (§6.10; risk-engine.md §8 E7)
defaulted/v1 (1_000_065, minted at gate G3) {0 pool (u32), 1 loss (u64 mills), 2 base (u64 mills)} — R6b's record (risk-engine.md §4, §11)
desk_period_result/v1 (1_000_082; RC1 v1 — records::credit; the credit-economics chain, risk-engine.md §6, under a caps_rule v5 document, futarchy-system.md §4 "The v5 chain") {0 pool (u32 — a bookmaker pool's wire index), 1 result (signed mills, CBOR major 0/1 — P_i, Σ pnl over the pool's return_observed rows of the window), 2 own_base (u64 mills — the cushion plus Σ top-ups), 3 recovery (u64 mills — 0 while no forced close exists, C85), 4 state (u64: 0 healthy, 1 margin_called, 2 liquidated, 3 defaulted — the desk's credit state at h*), 5 cushion_source (u64: 0 document — the cushion is own_capital (58); 1 predecessor — the predecessor's desk_account own_after for the sleeve, capped at 58; futarchy-system.md §9 C138)} — a closed map, one row per bookmaker pool ascending; stamped h* at the v5 layout's +6 + m + Y + j, j the pool's 0-based ordinal among the bookmaker pools (the first block of the 6n credit rows, allocation-market.md §5)
liquidation_fee/v1 (1_000_083; RC2 v1) {0 pool (u32), 1 recovery (u64 mills), 2 fee (u64 mills — ⌊recovery · liquidation_fee⌋, to POV before any principal, C29; fee ≤ recovery held by the decoder)} — one per bookmaker pool ascending, the second block
profit_split/v1 (1_000_084; RC3 v1) {0 pool (u32), 1 profit_credit (u64 mills), 2 profit_own (u64 mills), 3 share_lpv (u64), 4 share_pov (u64), 5 share_desk (u64) — every term 0 on P̃^desk_i ≤ 0 (the net result after the liquidation fee, risk-engine.md §6); share_lpv + share_pov + share_desk = profit_credit exactly (the decoder holds it)} — one per bookmaker pool ascending, the third block; the attribution profit_attribution_rule (89) v1 UTILISED, futarchy-system.md §9 C134
loss_waterfall/v1 (1_000_085; RC4 v1) {0 pool (u32), 1 loss (u64 mills — L^loss_i = −P̃^desk_i, 0 on P̃^desk_i ≥ 0), 2 own_absorbed (u64), 3 pov_absorbed (u64), 4 lpv_absorbed (u64), 5 beyond_line (u64) — own + pov + lpv + beyond = loss exactly (held by the decoder); Σ over the rows of pov_absorbed ≤ pov_reserve (88) held by the fold} — one per bookmaker pool ascending, the fourth block (C26)
grid_fee/v1 (1_000_086; RC5 v1) {0 pool (u32), 1 fixed (u64 mills), 2 share_of_retained (u64 mills), 3 fee (u64 mills — their sum)} — one per bookmaker pool ascending, the fifth block (C29, C137)
desk_account/v1 (1_000_087; RC6 v1) {0 pool (u32), 1 pnl (signed mills — P_i), 2 own_after (u64 mills), 3 lpv_after (signed mills, CBOR major 0/1 — V_i + share_lpv − lpv_absorbed, below 0 where a desk's credit exposure exceeded V_i under carry_budget_rule v2), 4 pov_after (signed mills — share_pov + fee − pov_absorbed), 5 grid (u64 mills), 6 beyond_line (u64 mills)} — one per bookmaker pool ascending, the sixth and last block; epoch_settled follows at +6 + m + Y + 6n; the rows the vaults move (nothing in the engine moves money); Σ lpv_after over the rows the LP's lpv_closing (futarchy-system.md §7, C127 v3)

The four desk records are minted at gate G3 by their first producer, the futarchy fold, and stamped with the triggering row's height. A desk-period block may read the futarchy chain's outputs (sys/return_observed/v1, B6's) and the epoch log's root records; no futarchy block reads a desk record; the document check's DAG runs over the union of fields 50 and 54 in execution order, acyclic by construction; the composition stamps the stream's offsets — a stamp is never a block input. The allocation stream's post-close records stand in ONE TOTAL ORDER: the triggering row's log position, then pool ascending, then the kind order margin_cleared | margin_called → liquidated → defaulted; a verdict's return_observed row first among its row's records, a top-up's margin_cleared at the top-up's log position. The offsets are the composition's layout over that order, every later record shifted by the desk records before it, the window-derived records (window_closed onward) keeping their relative order and standing after every record of the total order — a desk_topup's height is at most the window's terminal (h* at a close, g at an abort — E2's contract), so no triggering row follows the window's terminal (risk-engine.md §11; allocation-market.md §5).

6.18 Synth match log (root — the synthetic title of the market-dynamics simulation, futarchy-system.md §7 "The synth feed emitter") #

Why this section exists: the economist's betting-market-dynamics model (task 166; Linear BID-69) is a binary contract on a SHARED EVIDENCE FEED x_k read by every actor, not a game: the first START triage showed that deriving a cs2 map from such a feed cannot be done coherently (a lookahead construction, a leak on the deciding round), so the feed is a TITLE of its own — the smallest canonical shape the venue admits. synth takes the next thousand-block 3000..=3999 ("synth/…", §7 rule 4), owned by the synth crate — the ingest crate's record count stands, and the catalog's hash (§8) changes for every projection and report that carries it (a named consequence of the title: the catalog's record count moves 120 → 123 in every pinned count, with the reason in the pin's doc-comment; no literal hash is pinned anywhere in the tree — every comparison recomputes Catalog::hash()). The log has ONE producer, the synth feed emitter (emit-fixture synth, a fixture emitter under law 8(d) — futarchy-system.md §7), and no partner: nothing here owes data-conformance.

Records (schema ids 3000–3002; wire names synth/synth_discovered/v1, synth/synth_state/v1, synth/synth_complete/v1 — §7 rule 4's title prefix over the row names; the log's lineage the tag synth/match/v1 ‖ the 32-byte match id; body field ids integers):

Event Body fields
synth_discovered/v1 (3000) {0 match_id} — match_id (32 bytes) = BLAKE3("synth/match/v1" ‖ u64-LE(the feed stream's seed — futarchy-system.md §9 C142's per-name seed) ‖ u32-LE(run index)) — derived (law 5), so two runs of one case differ by their index and two cases by their seed; no roster, no source ref (the title has no source)
synth_state/v1 (3001) {0 k (u32 — the tick index, 0 ..= N, consecutive from 0, exactly one record per tick), 1 x (i64 — the evidence x_k in Q32 signed: the integer is x_k · 2³²)} — THE TITLE'S STATE RECORD: the game-state stream forwards it verbatim (the Synth projector arm), every actor and the house read it and nothing else; its state_version is the state stream's offset, and with synth_discovered forwarded tick k sits at offset k + 1 (the tick → offset map the dynamics driver of part B states)
synth_complete/v1 (3002) {0 match_id, 1 team_a_win (u64: 1 Team A won — the outcome Y —, 0 Team B won; any other value refused), 2 finality (u64: 0 canonical — the one admitted value, any other refused)} — the completion and the match-winner family's one finality (settlement-contract SC-7 rule 2; the WinnerTitle seam reads (match_id, side) from it: Team A is the perspective side, wire 0); after the N-th state and nothing after it

Normative rules:

  1. Envelope. replay_ts in milliseconds: feed_watch_armed and synth_discovered at t_0, state k at t_k = t_0 + k · Δ, synth_complete at t_0 + (N + 1) · Δ, seq the running index from 0; t_0 and Δ (above 0) are the emitter's arguments (--t0, --delta; the case's feed.delta under part B's dynamics block), the sleeve 0. Every tick is a state-emitting tick by construction, so an instruction stamped at tick k's state version joins the fold at tick k + 1 (architecture.md "The external drive rule and the client-ladder entry", rule 1 — merge-position consumption, the stamp-named version).
  2. Feed health. The log is a match log under the feed rule R7 (risk-engine.md §3): sys/feed_watch_armed/v1 {rule_ms, continuity} (§6.13 rule 12 (d)) is its FIRST record, written by the emitter from its --stall-ms / --continuity arguments, and rule_ms > Δ is a gate (the log never stalls under its own rule — a stall would pull every actor's ladder, risk-engine.md §3 R7). The lineage tag synth/match/v1 is a row of the ingest crate's tag registry (title synth, ONE source, the synth generator — the tag's declared sources are the stall record's source vocabulary), so a document's feed_rules row (allocation-market.md §3 field 78) may name it.
  3. The evidence and the outcome. x_0 is the case's; x_k = x_{k−1} + σ_f · z_k with z_k the k-th draw of the feed stream through the ν table (futarchy-system.md §9 C142; the product rounded to nearest, ties to even). Y is ONE draw of the outcome stream taken AFTER the N-th state (outcome_rule v1, futarchy-system.md §9 C140): team_a_win = 1 iff u < L(x_N) with L the base-2 logistic under the unit rule (C141). No record before synth_complete, and no actor input, depends on Y (the leak gate of part B).
  4. The family. synth/match_winner — the §6.9 market_created row's match-winner family bound to the title (settlement-contract SC-7): created on the forwarded synth_discovered, closed on the forwarded synth_complete, the payout ingest::match_winner::settlement_price (1000 mills to the claim's side if it won); NO void path — a log without synth_complete never settles and its positions stay locked (SC-5's posture), so the emitter ALWAYS writes the completion.
  5. The feature schema. One feature, x, on the i32 lane as Q16, the schema named synth/v1: the lane value is the CHECKED conversion i32::try_from(x >> 16) (an arithmetic shift — the floor —, the bounds ±2³¹ in Q16, so every Q32 value in [−2⁴⁷, 2⁴⁷) is exact to the lane's resolution and one outside it is refused EvidenceBeyondLane {x} by the extractor — a typed halt of the synth extractor, never a clamp), its FeatureSchemaId the hash of that declaration (model-plugin-layer §8) in the schema_for_id registry. The emitter refuses a feed whose Q32 integer reaches abs(x) ≥ 2⁴⁷ (an evidence abs(x_k) ≥ 2¹⁵) as EvidenceBeyondLane {k} — the bound symmetric, so −2⁴⁷ is refused though the lane carries it; a step leaving i64 altogether is refused one check earlier as the feed step's Overflow — and the CONSUMER refuses it too: the projector halts a state with abs(x) ≥ 2⁴⁷ as EvidenceBeyondLane {k} (rule 7), since the bytes on disk bind no producer — the same name in three error types, the emitter's and the projector's carrying the tick k, the extractor's the value x (it sees no tick); so the extractor's refusal is unreachable from a log the projector admitted — never a silent repair of a hostile value. No house model for the title is declared in task 166 (the dynamics runs keep the house silent — futarchy-system.md §9 C140).
  6. The lineage. The segment's lineage is the tag synth/match/v1 followed by the 32-byte match id (futarchy-system.md §11 W23); the manifest check decodes it and hands the ref (CheckedMatchLog) to the scheduler's bind (Scheduler::bind_match_lineage, the gateway driver's alike) BEFORE the first record — a bind after the cursor moved is refused MatchLineageAfterStart —, and the projector refuses the FIRST discovery when its match_id is not that ref (a later discovery is rule 7's DiscoveryWhileLive whatever its id — the precedence). A segment the bind never handed a ref (a lineage the manifest check admitted without a synth ref) FAILS CLOSED: every discovery is refused MatchIdNotLineage {lineage: None}.
  7. The log's shape, enforced by the projector. The record decoders check the bodies; the Synth projector checks the SEQUENCE and halts the run with a typed error on: a state before the discovery (StateWithoutMatch), a tick out of order (TickOutOfOrder — k not the next expected, whatever its x: the order check precedes the bound), a second synth_discovered while a match is live (DiscoveryWhileLive — never a reset of the tick counter), a completion before two states exist (CompleteBeforeTicks {states} — N ≥ 1 gives k = 0 and k = 1; a completion before any discovery reports it with states: 0), any record after the completion (RecordAfterCompletion — one match per log, the lineage names one), a discovery whose match_id is not the segment lineage's 32-byte ref (MatchIdNotLineage, rule 6), a state with abs(x) ≥ 2⁴⁷ (EvidenceBeyondLane {k}, rule 5), and a tick counter past u32 (TickExhausted); a completion naming another match than the live one halts in the match tracker as on every title (settlement-contract SC-7 rule 4, CompletionMatchMismatch); the emitter's counted cap ticks ≤ 1 048 576 (CONFIG, futarchy-system.md §11 W23) is checked before any allocation. The segment on disk is untrusted bytes: the emitter is a fixture-class producer and these rules, not trust in it, are what the fold relies on.
  8. Serving. A synth run is served by the scheduler as any title's (the Synth arm of the five plug enums — projector, policy, extractor, router, outcome — and Title::Synth in the manifest with default_market_family = match_winner, no line families; cs2, lol and dota2 manifests' bytes and run ids are unchanged, a gate). The sandbox's serve list stays cs2 / lol / dota2 (ops/sandbox, a deployment choice — the gateway has no title gate), so no synth run reaches a Python SDK client, which halts on an unknown state schema.

7. Schema evolution rules #

  1. Schemas are immutable. A change of any kind = new version (fill/v2), new schema_id. Old records decode forever under their original schema. Immutability binds from the moment records exist outside this repo's own change-controlled fixtures — i.e., from the first persisted registry FILE (§7 rule 5; the boundary is the registry file's existence, not the first manifest). Before that boundary a schema may be amended in place, recorded on the affected row (fill/v1's taker arity and its taker_is_buy; the optional family on settlement_complete/v1 and void_complete/v1 — absence keeping the earlier semantics).
  2. Consumers declare the versions they read. A transformer meeting an undeclared version halts its stream (a lifecycle halt, not a crash) — never guesses.
  3. A field id is never reused for a DIFFERENT meaning, and removal retires an id permanently. "Reuse" here is the harmful kind: recycling a retired id for an unrelated new field. Carrying a field FORWARD into a new version — same semantic field, same id, under a new schema_id — is continuity, not reuse, and is the expected shape (first exercised by the dust2/v2 enrichment): tick_state/v2 keeps tick_state/v1's body ids (TS_PLAYERS/TS_BOMB_STATE/TS_CLOCK_TICKS) and state/v2 keeps state/v1's team-map ids (TM_KILLS..=TM_POSITIONS). This is safe because a record is decoded by its schema_id first (every from_record opens with expect_schema), so the v1 and v2 field-id spaces never meet in one decoder. A field's VALUE may also evolve across versions under its carried-forward id — tick_state/v2's players array grows from 8 to 16 elements under the same TS_PLAYERS id — and the distinct schema_id is exactly what makes that unambiguous. What stays barred: within one version's id space, two meanings on one id; and giving a removed id a new meaning in a later version.
  4. Titles own reserved SchemaId ranges with title-prefixed names : cs2 = 1..=999 ("cs2/…"), dota2 = 1000..=1999 ("dota2/…"), lol = 2000..=2999 ("lol/…", §6.15), synth = 3000..=3999 ("synth/…", §6.18 — the synth crate's), each new title takes the next thousand-block — the global id space can never collide across titles in a shared registry. Title blocks may never exceed 999_999 (room for 1000 title blocks, the last being 999_000..=999_999); title-shared platform streams — every stream whose events are not a single title's vocabulary: lifecycle (§6.9), settlement (§6.8), market-event (§6.7), order log (§6.6), quote (§6.5), control (§6.10) — own the region 1_000_000..=1_000_999 with names prefixed "sys/". The region by owner — each range minted with its first producer or its first declared consumer (rule 2 — a reader declares the version it reads, so the id must exist), vocabulary homed in the ENFORCING crate so producers and consumers depend on it acyclically: 1_000_000..=1_000_007 the §6.9 lifecycle events and the two §6.8 barrier events (replay); 1_000_008..=1_000_017 sys/quote_batch/v1 (§6.5), sys/order/v1 and sys/account_created/v1 (§6.6) and the seven §6.7 market events (exchange — the enforcing layer for all three streams; producers bookmaker and gateway depend on it); 1_000_018 sys/payout/v1 (§6.8 — homed with the two barrier events in replay, the lowest common dependency of its producer, clearing, and both consumers, replay's open barrier and exchange's §6 settlement feedback; homing it in clearing would put a cycle between clearing, which decodes exchange fills, and exchange, which decodes payouts); 1_000_019..=1_000_026 sys/features/v1 (§6.3), sys/distribution/v1 (§6.4) and the six §6.10 events (one of which — model_output_invalid — rides the derived model_health stream per the writer split) (pricing — the enforcing layer for all three streams: the extractor derives features, the wrapper derives every probability stream, the registry fold enforces the control state machine); 1_000_027..=1_000_028 sys/forecast_submit/v1 and sys/ladder_submit/v1 (§6.6) (bookmaker — the quote fold consumes them and their bodies bind to the pricing distribution contract, which the order log's other owner, exchange, must not depend on; the producer, gateway, and the routing consumer, runtime, depend on bookmaker); 1_000_029 sys/void_refund/v1 (§6.8 — beside payout/v1 in replay for the same reason); 1_000_030..=1_000_060 the Allocation Layer's records (complete — 1_000_054 minted by B14b, task 143 step 3i) (§6.16 the epoch log, §6.17 the allocation stream; allocation-market.md §5) — the six epoch-log records and document_checked, book_closed, raw_share, capped_share, allocation, epoch_aborted at 1_000_030..=1_000_041, then return_observed 1_000_042, stake_moved 1_000_043, window_closed 1_000_044, utilization 1_000_045, winsorized 1_000_046, estimate 1_000_047, feasible_set 1_000_048, settlement 1_000_049 (minted with its first declared consumer, the certificate's verifier), pool_liability 1_000_050, stake_payout 1_000_051 and epoch_settled 1_000_052, the v1 region's last id; capped_share/v2 1_000_055 and allocation/v2 1_000_056 minted by B3 v2 and B4 v2, balance_series/v1 1_000_053 by B6b, utilization/v2 1_000_057 by B8 v3, winsorized/v2 1_000_058 by B9 v2, feasible_set/v2 1_000_059 by B11 v2, settlement/v2 1_000_060 by B12 v2 and participation_yield/v1 1_000_054 by B14b (task 143) — every record of the v1 chain minted (futarchy — the gateway, the fixtures and the fold depend on it acyclically; the epoch log's ids minted with the fold as their first declared consumer, the dev-fixtures emitter producing them until the gateway produces them); 1_000_061..=1_000_067 the risk engine's records (risk-engine.md §11), MINTED by their first producers, with two owners: 1_000_061..=1_000_065 the futarchy crate (desk_topup 1_000_061, a root record minted at gate G3 — the fixture emitter until the chain reader E2 produces it; margin_called 1_000_062, margin_cleared 1_000_063, liquidated 1_000_064, defaulted 1_000_065 — the futarchy fold from gate G3), 1_000_066..=1_000_067 the pricing crate's control events module (sleeve_suspended 1_000_066, sleeve_resumed 1_000_067 — the operator gateway from gate G5). 1_000_068 run_included/v2 (futarchy — the shared-line inclusion row, futarchy-system.md §4 B5 v3), the next id past the risk engine's block. 1_000_069 balance_series/v2 and 1_000_070 settlement/v3 (futarchy — B6b v2 and B12 v3 of the v3 chain), minted by their first producers. 1_000_072 epoch_aborted/v2 (futarchy — B1 v2 and B7 v5 of the v3 chain), minted by its first producer; 1_000_071 stays free. 1_000_073 sys/feed_watch_armed/v1, 1_000_074 sys/feed_stalled/v1 and 1_000_075 sys/feed_recovered/v1 (the ingest crate's universal feed module — the title-agnostic feed-health records any title's match log may carry, §6.13 rule 12 (d); risk-engine.md §11), minted by their first producer. 1_000_076..=1_000_080 the operator-routed retail records (exchange — the enforcing layer for the order log and the market-event stream, as for 1_000_008..=1_000_017; their order-log producer, gateway, depends on it): sys/subaccount_opened/v1 1_000_076 and sys/instruction_relayed/v1 1_000_077 (§6.6), sys/instruction_rejected/v1 1_000_078, sys/retail_rested/v1 1_000_079 and sys/retail_cancelled/v1 1_000_080 (§6.7), minted by their first producers. 1_000_081 sys/carry_settled/v1 (futarchy — the epoch log's vault carry settlement row of a caps_rule v4 chain, §6.16, records::log), the next id past the operator block, minted with the fold as its first declared consumer. 1_000_082 desk_period_result/v1, 1_000_083 liquidation_fee/v1, 1_000_084 profit_split/v1, 1_000_085 loss_waterfall/v1, 1_000_086 grid_fee/v1, 1_000_087 desk_account/v1 (futarchy — RC1–RC6 v1 of the risk chain v4, records::credit, under a caps_rule v5 document; risk-engine.md §6), minted by their first producer. A cross-crate uniqueness test over every sys/ owner's constants lives in dev-fixtures (tests/futarchy_fold.rs).
  5. The schema registry is itself content-addressed: schema_id resolves through a registry file whose hash is pinned in the run manifest — two runs can't silently disagree about what fill/v1 means. The catalog of §8 is the registry's content and its hash. No registry FILE is persisted, so the boundary of §6.1, §6.11 and rule 1 is unchanged, and the pin in the run manifest is carried by the projection's provenance until the manifest has the field.
  6. Revisions/corrections are events (revision/v1), per determinism rule 3 — schema evolution never rewrites history.

8. Projections: JSONL (debug) and Parquet (analytics) #

Projections are read-only, derived, discardable — never authoritative, and nothing in the system ever reads one back. Both carry provenance (the source segment hashes), so even derived views are traceable to truth. Beside the projections, the RUN REPORT of a recorded scenario artifact (futarchy-system.md §7 "The run report", §11 W18; W15's class, as d2-cycle report) reads segments, documents, manifests, the artifact's invocation record, its copied expectations document and, on a matrix case tree, its copied case document (futarchy-system.md §11 W19) through this renderer and never a projection; its provenance names every file it read by its path relative to the artifact.

JSONL — the human/debug projection. d2s cat <segment> [--from OFFSET] [--to OFFSET] (crate crates/projection, bin d2s) renders a range of ONE segment file as JSONL on stdout. The argument is a file, not a stream: every writer keeps a stream in one file and none rolls segments. Every object's keys are sorted bytewise at every level, no clock read; the lists below name the keys, not their order. Line 1 is the provenance object, with the fields projection ("jsonl/v1"), file (the caller-supplied name — d2s cat passes the file's basename, d2-scenario the path relative to its output directory, futarchy-system.md §11 W16), file_blake3 (the blake3 of the file's bytes, hex), format_version (the header's), stream_id (hex), base_offset, prev_end_hash (hex), lineage (the header's lineage record decoded, its integer keys as decimal-string JSON keys and its values under the rendering rules below, never counted in unnamed; lineage bytes that are not a map render as hex under lineage_raw instead), sealed (the footer is present), torn_bytes (the length of the discarded torn tail, the only signal that one was dropped), records (the records the reader returned, equal to the footer's count on a sealed file), from, to, unnamed, unknown and catalog_hash (the catalog's hash, below). Then one object per record in offset order, with the fields offset, schema (the catalog's name, or null), schema_id, replay_ts, seq, sleeve_id and body.

OFFSETS are stream offsets (base_offset + i for the file's i-th record); --to is exclusive and the two flags default to the file's range. The legal range of both flags is the closed interval base_offset ≤ offset ≤ base_offset + records, and to == base_offset + records is the default to. from == to is legal and renders the provenance line only; from > to, or an offset outside that interval, is refused. to − from is the rendered count.

RENDERING follows the CBOR type. An integer whose magnitude is at most 2^53−1 (Uint(n) with n ≤ 2^53−1, Nint(n), the value −1−n, with n ≤ 2^53−2) renders as a JSON number, any other integer as a DECIMAL STRING, so every 64-bit reader and JavaScript's JSON.parse read every value losslessly. Bytes render as lowercase hex; Text, Bool, Null and Array as their JSON counterparts. A Map renders as an object keyed by the catalog's names at EVERY DESCRIBED LEVEL (a Map-valued field, or the Map elements of an Array-valued field, each described by its nested table, as deep as the owner describes); a key the catalog does not describe renders as "_<id>" and counts in unnamed; a Map below the described levels (a Map inside nested arrays included) renders its keys as decimal strings and counts them in unnamed. A by_schema field whose sibling key is absent or names a schema the catalog lacks renders below the described levels. A record whose schema the catalog lacks has "schema": null and counts in unknown; an unknown record's body renders below the described levels.

Exit codes: 0 written; 1 refused, with the reason on stderr (from > to, or an offset outside the file's range); 2 could not read (an Io fault, or a segment file that does not decode — a bad header, a bad record, a chain or footer mismatch); a catalog that does not collect is 2; a usage error is 2, as d2-verify and d2-cycle exit it. A refusal or a read failure writes nothing to stdout. This is the three-bucket convention d2-verify and d2-cycle share (futarchy-system.md §7).

The record catalog — the registry of §7 item 5. The catalog is OWNER-DESCRIBED. stream_core::desc holds the two description structs, RecordDesc { schema, name, fields } and FieldDesc { id, name, nested, by_schema }. Each crate that mints schema ids exports describe_records(). A record's NAME comes through the crate's seed name function (the per-title schema_name, exchange_schema_name, pricing_schema_name, sys_schema_name, futarchy's MINTED; bookmaker and the feed events carry a schema_name of the same kind) and is never spelled a second time. The FIELD names are spelled once per record, in a FieldDesc table in the record's own file beside its id constants; they are the names of §6's Body fields column (the {k name} listing where a row has one, else the row's prose names). A constant shared by two records carries each record's own name in that record's table: bookmaker's payload key is p in forecast_submit/v1 and levels in ladder_submit/v1. nested describes the level below a key, recursively: a Map-valued field, or the Map elements of an Array-valued field. A field whose keys are another record's (revision/v1's corrected_body, §6.1) names the sibling key carrying that record's schema id; the projection names it through that schema's entry; a by_schema field has no nested table. The epoch document's keys are described by futarchy::document::describe_document(). The collector refuses a duplicate schema id, a duplicate field id within one table (a record's fields or a nested level), an empty name, a RECORD name outside ([a-z0-9_]+/)*[a-z0-9_]+/v[0-9]+, and any name that is not NFC; the empty and NFC refusals cover every name (record, field, nested), the grammar refusal record names only.

The CATALOG HASH is blake3 over the domain tag catalog/v1 prefixed by its length (the tag's length as a u64 big-endian, then the tag), followed by the catalog's canonical CBOR encoding {schema_id: {0 name, 1 fields: [{0 id, 1 name, 2 nested[], 3 by_schema}]}}. fields[] and nested[] are sorted by id; each nested[] entry has the same shape, recursively; key 2 is ALWAYS present at every depth (an empty array when a field has no nesting); key 3, the sibling key of a field whose keys are another record's, is present only when set. The epoch document's entry is {1: fields[]} (no name) under the reserved key 2^32, a u64 above every SchemaId (which is u32). The hash is infallible over a collected catalog, and every projection's provenance carries it.

Parquet — the analytics projection (specified, not built). d2s export-parquet <stream> [--from OFFSET] [--to OFFSET] exports sealed segments as columnar tables (one table per event type, envelope fields + flattened body columns) for the research side — DuckDB, pandas, model training pipelines. Columnar is exactly right here: research scans columns across millions of records; it is exactly wrong as truth (no record-level append, no byte-stable records for chains). Sealed-segments-only keeps the export boundary aligned with immutability.