K11 is implemented: crates/cb-events/src/store.rs, magic + version header, 4-byte little-endian length prefix, append-only. Reimplemented not assimilated per ADR-0005 §2 — no new dependency, and AM-4a/AM-4b are unchanged at 246,250 / 317,021 because nothing entered the graph. The operative clause is "detected", so corruption is tested rather than assumed: a tail short by one byte, a half-written length prefix, a length prefix corrupted to claim more than the file holds, foreign magic, and a future format version are each rejected with a distinct error. A reader that accepts a truncated tail is worse than no format, because it silently returns a short history that looks complete. AM-11 is earned. LogStore has two impls — MemLogStore and FileLogStore — driven through ONE conformance(). The trait carries raw/set_raw precisely so the corruption controls live in the shared suite: a format contract that only one impl enforces is not a contract. The same shape is retro-fitted to KernelRng, which is what AM-11 actually names: ChaChaRng and NullRng now pass one suite asserting bounds, draw(1) == 0, determinism across fresh instances, and shuffle preserving the multiset. They were previously exercised by two separate tests, which is why "met, narrow" was never earned and ADR-0005 §4 downgraded it. K9 gets the assertion it did not have: snapshot at seq N + events N+1..M must equal the from-genesis fold, hash-compared, on GroundState, single-seed on purpose — AM-7's probe folds a multi-seed log, which is not a replay of anything, and that defect is not repeated. Two positive controls: the log must exceed 50 events, and the mid-log snapshot must differ from the end state or "apply the remainder" is vacuous. Proof it works: the exact mutation that SURVIVED in CB-WP-0005 — making Snapshot::take discard its EventSeq — now fails on the K9 assertion. AM-11's mutation breaks NullRng::draw to return its bound and the shared suite fails. That is what M-D4-SWAP claims — either impl substitutable — and exactly what two separate per-impl tests could never demonstrate. M-D1-MUT: 7 -> 8 of 14. CB-EV-0001's scoreboard is refreshed: AM-2, AM-5 and AM-9 added, AM-6 moved to enforced, and the headline total corrected from 4 to 8 — it had gone stale inside the same workplan that produced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
13 KiB
Game Kernel — specification and acceptance metrics
Status: v0.1 draft — implements ADR-0002 (reimplement in Rust,
assimilate patterns). Governs crates cb-kernel, cb-events,
cb-game-runtime, and the first game package games/ground.
Baselines referenced: research/CB-RES-0001-game-kernel.md.
Instruments: specs/MetricsAndScenarios.md. Rules content:
specs/GroundRules.md.
The kernel is the authoritative semantic layer of the Clay-Borg architecture (Blueprint §1): headless, deterministic, replayable. No rendering, no physics, no networking — those attach later through ports and must never leak types into anything specified here (M-D4-LEAK = 0).
1. Crate boundaries
| Crate | Owns | Must not know about |
|---|---|---|
cb-kernel |
canonical IDs, RNG service, command/event/fold traits, clock-free tick model | any concrete game, serialization format details |
cb-events |
event envelope, append-only log, snapshots, canonical serialization, state hashing | game semantics |
cb-game-runtime |
round/phase machinery, simultaneous commit windows, per-player projections, scenario-runner API | GROUND specifics |
games/ground |
GROUND rules: state aggregate, commands, events, reducers per specs/GroundRules.md | anything below cb-game-runtime's public API |
Dependency rule: games/ground → cb-game-runtime → cb-events → cb-kernel.
No cycle, no skip that bypasses a public API. External crates allowed in
the headless kernel workspace: serde (+format crate), a seedable RNG
(e.g. chacha), a hash (sha2), thiserror-class error derive. Weight is
budgeted as third-party source under audit, split by build
configuration — see AM-4.
Scenario parsing is dev-only. cb-game-runtime's scenarios feature
carries the YAML dependency; a shipped game runtime builds with
--no-default-features and parses no YAML. Workspace dependencies on
cb-game-runtime and games-ground therefore set
default-features = false, and consumers that need scenarios (currently
cb-sim) opt in explicitly.
2. Canonical model
2.1 Identifiers (cb-kernel)
Newtyped, copyable, ordered: GameId, PlayerId, EntityId
(problems, tokens, relations), CommandId, EventId (monotonic
u64 sequence per game), Seed (u64). No UUIDs inside the hot
path; string forms only at the boundary.
2.2 The mutation pipeline (assimilated: cqrs-es pattern)
Command (actor-tagged intent)
→ validate(&State, &Command) → Result<Vec<Event>, Rejection>
→ fold(&mut State, &Event) # infallible, total
→ EventLog::append(events) # with per-event hash chaining
- K1 All state change flows through events; there is no other mutator.
foldis infallible: every validation that could fail happens invalidate, so a logged event always applies. - K2 Commands carry:
CommandId, issuingPlayerId(orSystem), round number, and payload. DuplicateCommandIdwithin a game is rejected (idempotency). - K3 Rejections are typed and testable (
Rejection::StressGate,::NotYourTarget,::SlotOccupied, …); scenarios assert on them (expect.rejects). - K4 Events are closed enums per game package, serialized through a
versioned envelope:
{seq, game_id, round, kind, payload, schema_ver}.
2.3 Determinism (assimilated: Rune's enforced discipline)
- K5 The only randomness source is the kernel RNG service, seeded from
Seed; RNG draws are themselves events (or deterministically derived from the event sequence) so replay never re-rolls. - K6 No ambient time, no OS entropy, no pointer/hash iteration order:
all iterated collections in semantic state are ordered (
BTreeMap/Vec);HashMapis forbidden ingames/*state. Enforced by lint/deny in CI, not convention (AM-8). - K7 State hash = SHA-256 over the canonical serialization of the aggregate; computed on demand and recorded at round End events.
- K8 Double-run invariant: the scenario runner executes every scenario twice with the same seed and fails on any hash divergence (MetricsAndScenarios §2).
2.4 Snapshots and replay (cb-events)
- K9 A snapshot is the canonical serialization of the full aggregate +
the
EventIdit includes.snapshot + remaining events → statemust be hash-identical to a from-genesis fold (AM-7). - K10 A replay bundle (
.cbreplay, MetricsAndScenarios §4) contains manifest, command log, initial snapshot, and failed expectations; the runner's--replayre-executes it bit-identically. - K11 Log format is append-only, length-prefixed, versioned; a truncated tail is detected, not silently accepted.
2.5 Simultaneity and hidden information (cb-game-runtime)
- K12 Commit window primitive (assimilated: GROUND's Select/Reveal,
boardgame.io
activePlayersshape): the runtime opens a window naming the players who must submit; submissions are recorded as commitment events whose payload is hidden in projections until the window's reveal event; late/duplicate submissions are Rejections. - K13 Projection (assimilated: OpenSpiel information states): for each
PlayerId(andSpectator), a total functionproject(&State) → PlayerViewthat structurally cannot include: other players' unrevealed commitments, face-down problem identities, other players' hands. Projections are derived views — never inputs tovalidate/fold. - K14 The GROUND round (GR-R01..R09) is expressed through runtime primitives: one commit window per Select, reveal, then fixed-order resolution steps with Lead-order iteration (GR-R06/R07) driven by system commands.
2.6 GROUND aggregate (games/ground)
- K15 State implements specs/GroundRules.md §1 exactly; every GR-rule
is realized in
validate/foldand cross-referenced by rule ID in doc comments, giving a greppable rule→code→scenario chain. - K16 U-item defaults (GroundRules §Underdetermined) are implemented
behind clearly named functions so a ground-game ruling is a localized
change; their scenarios carry
provisional: true.
3. Scenario runner and CLI surface
- K17
cb-game-runtimeexposes the scenario runner as a library; a thin binary (cb-sim, precursor ofcb sim) runsscenarios/ground/*.yamlper MetricsAndScenarios §2: setup presets, ordered actor-tagged commands, partial end-state assertions,covers,rejects, optionalstate_hash. - K18 Benchmarks are Criterion benches driving the same scenario format at scale (MetricsAndScenarios §3); the synthetic workload mirrors the CB-RES-0001 harness shape (3 players, commit/reveal rounds) for same-shape comparison.
4. Acceptance metrics
On AM-4's retarget (2026-07-31). Ratified by ADR-0004 on 2026-07-31; the headroom argument for why these ceilings bind on future work lives there. AM-4 originally read "≤20 transitive crates", set against boardgame.io's 120 npm packages. That target was retired for two measured reasons. First, it was unreachable without undoing this spec's own contracts: K5 (seeded ChaCha) and K7 (SHA-256) cost 12 crates between them, and the measured ladder showed nothing reached 20 except reimplementing one of those primitives — which trades an audited implementation for a scoreboard number. Second, crate count does not compare across ecosystems: Rust splits crates far more finely than npm, so the original 33-vs-120 comparison flattered us while the ≤20 target punished us, both for the same reason.
Third-party source under audit is what the count was a proxy for, it is comparable across ecosystems, and it cannot be gamed by crate granularity. Splitting it by build configuration also makes the dev/shipped distinction visible, which the single number hid. (the code loop's exit condition)
Per InnerLoop step 4/5: T08 iterates until every row meets its target;
evidence lands in evidence/CB-EV-0001-game-kernel.md with no
unmeasured. Baselines from CB-RES-0001; cited-only rows cap at parity.
| ID | Metric | Baseline (CB-RES-0001) | Target | Verdict basis |
|---|---|---|---|---|
| AM-1 | M-D1-COV: GR-rules covered by ≥1 passing scenario | no candidate has any (observation) | 100% of GR + U rules | measured by runner report |
| AM-2 | M-D1-SPL: spec lines per rule in games/ground rules code (impl LOC ÷ rule count) |
boardgame.io ~36 LOC for the 2-move synthetic game | ≤ 40 LOC/rule, paired with AM-1 (anti-gaming pair) | measured (tokei + rule count) |
| AM-3 | Synthetic-workload definition size: LOC to express the CB-RES-0001 synthetic game on our kernel | ~36 LOC (boardgame.io, measured) | ≤ 50 LOC | measured |
| AM-4a | M-D2-DEP: third-party LOC, shipped runtime (--no-default-features) |
boardgame.io: 120 npm packages / 3.9M LOC | ≤ 250,000 lines | measured (make dep-weight) |
| AM-4b | M-D2-DEP: third-party LOC, dev toolchain (default features) | as above | ≤ 350,000 lines | measured (make dep-weight) |
| M-D2-DEP: own source per third-party 100k lines | — | WITHDRAWN from the acceptance table 2026-08-01 (CB-WP-0006 T04) — retained as a reported diagnostic in make dep-weight; see §5a |
diagnostic | |
| AM-5 | M-D2-BLD: clean release build of headless workspace | n/a (npm install ~seconds; not comparable) | ≤ 60 s on bnt-lap001, recorded not gated | measured |
| AM-6 | M-D3-THR: applied events/s, synthetic workload, same machine | boardgame.io ~1,100–1,900 moves/s (best config, degrading) | ≥ 100,000/s (stipulated target, ADR-0002) | measured |
| AM-7 | M-D3 scaling: throughput @100k events vs @5k; and snapshot+replay of 100k events | boardgame.io 0.45–0.66× @20–40k, DNF @100k | ≥ 0.9× (flat), replay of 100k events ≤ 5 s, hash-identical | measured |
| AM-8 | Determinism invariant: N=10 same-seed replays, bit-identical hashes; HashMap-in-state deny lint clean | Rune: enforced by tooling (cited) | zero divergence, lint clean in CI | measured (invariant, not a verdict row) |
| AM-9 | M-D3-MEM: peak RSS, 100k-event synthetic run | boardgame.io ~100→232 MB @5k→40k (indicative) | ≤ 64 MB, flat with history given snapshot interval | measured (indicative label, same method) |
| AM-10 | M-D4-LEAK (withdrawn 2026-07-31, ADR-0005 §4 — no cb-*-api crate exists, so the population is empty; the clippy HashMap/HashSet deny that stood in for it cites K6 determinism and is now reported as AM-10′): foreign types in canonical-interface signatures |
boardgame.io: JS-ecosystem-locked | 0 | measured (grep/deny rule) |
| AM-11 | M-D4-SWAP (met 2026-08-01 — KernelRng and LogStore each drive one shared conformance(); CB-WP-0006 T05): null + reference impls passing one conformance suite |
no candidate has the pattern | RNG and log storage each have ≥2 impls (real + test/null) under one suite | measured (bool) |
| AM-12 | M-D2-TOK / M-D2-CST: tokens and USD per completed task | n/a — first pass sets our own baseline | recorded per task in the evidence cost log (price sheet 2026-07-31) | recorded, not gated |
5a. Why AM-4c was withdrawn from the acceptance table
(CB-WP-0006 T04, 2026-08-01. Tier S — amends one row, creates no capability; chaos d4=2, no override. Follows the precedent ADR-0005 §4 set for AM-10.)
AM-4c was reported, not targeted, so nothing could fail and it counted
against M-D1-MUT. The task was to give it a threshold or drop it. It is
dropped, for a reason that a threshold cannot fix:
The ratio has no monotone better direction. INTENT's rule is own the semantics; assimilate the implementation. A rising ratio can mean we are properly owning semantics, or that we are reimplementing things we should have assimilated. A falling ratio can mean good leverage, or dependency bloat and implementation leaking into our semantics. Both directions are ambiguous, and a target requires knowing which way is better.
It is also redundant. AM-4a and AM-4b already bound the denominator (third-party LOC ceilings, ratified in ADR-0004) and AM-2 bounds own-source density per rule. AM-4c is the ratio of two quantities that are each already targeted; any threshold on it would be implied by those two or would contradict them.
Measured at withdrawal: 1,426 own lines per 100k third-party (shipped runtime), 1,107 (dev toolchain).
Decided before Phase B, deliberately. ADR-0005 predicts own-source growth from the kernel work, which will move this ratio. Setting a threshold after seeing that movement would be the retarget InnerLoop §Step 4 forbids — so the decision was taken while the number was still unaffected by the work that will change it.
M-D1-MUT keeps AM-4c in its denominator. Withdrawing a row would otherwise improve the metric from 7/14 to 7/13 without enforcing anything — a score improved by deleting the question.
Comparisons against the event-sourcing 10⁵–10⁶/s estimate stay parity until a local Rust comparator is measured (open follow-up from the adversarial review).
5. Out of scope for this pass
Networking/session protocol, WIT/Wasm game boundary, ECS world layer, rendering, persistence beyond file snapshots, host migration, and any second game. Each arrives through its own loop pass with its own survey.