03-architecture.md · 123 requirements

03 — Architecture: the @everylastcity/core Simulation Package

This document specifies the technical spine of Every Last City: the @everylastcity/core deterministic simulation package — its state model, the Order/Event contract, the exact-arithmetic and determinism discipline, the randomness policy, per-player fog-filtered views, the save/replay container format, snapshotting, performance strategy, the headless CLI/server runner, runtime targets and the WASM plugin boundary, versioning and migration, and the automated testing strategy.

This document owns machinery, not rules. What a turn is and when orders are evaluated is owned by 10-turn-model.md; what an order can say by 13-command.md; map, terrain, movement and fog semantics by 01-game-rules.md; unit data and production by 02-units-and-industry.md; combat by 11-combat.md; economy by 12-economy.md; victory by 14-victory.md. This document defines the machinery that executes all of them identically on every platform, and it MUST NOT define a rule any of them owns.

The implementation language is TypeScript (strict: true), executed natively by V8 under Node 22 LTS on servers and tools and by the browser's own JavaScript engine on the client. That choice moves determinism from a language guarantee to an engineering discipline — TypeScript has exactly one numeric type and it is float64 — so §2 is the load-bearing section of this document and its enforcement is a Phase 0 deliverable.

Status: Draft v0.2 · Owner: unassigned · Depends on: docs/design/00-direction.md and docs/design/01-decision-turn-model.md (binding sources, not requirement documents); 00-overview.md (pillars, glossary, cross-document discipline); and the current rules documents whose contracts this machinery executes — 01-game-rules.md, 02-units-and-industry.md, 10-turn-model.md, 11-combat.md, 12-economy.md, 13-command.md, 14-victory.md.

Cited but not depended on. This document cites 04-ui-ux.md, 05-multiplayer.md, 06-ai.md, 07-modding-content.md, 08-services-platform.md and 09-roadmap.md for surfaces they own. All six are direction-stale (00-overview.md §5): they predate the withdrawal of the fidelity premise, and every identifier this document cites in them MUST be re-resolved when they are reconciled. Two of those citations are load-bearing rather than merely locating — the fuel-instrumentation transform of AR-855(c) is specified by 06-ai.md AI-410 and 07-modding-content.md MOD-610, and the transport obligations of AR-495 and AR-565 are discharged by 05-multiplayer.md — and each is called out where it appears. Everywhere else this document states its own obligation and names the owner rather than depending on the stale text.

Note on rights: this document is written clean-room (docs/design/00-direction.md §2). No number, name, table, format or test in it is derived from another product, and no requirement here is justified by another product's behaviour (00-overview.md OV-040).


1. Library layering and dependency rules

AR-010 @everylastcity/core MUST be a pure TypeScript package compiled to ES2022 ES modules, with strict: true and zero runtime dependencies on the DOM, Node built-ins (node:fs, node:crypto, node:worker_threads, …), Phaser, React, Colyseus, Fastify, or any UI/engine/OS-specific package. It MUST import identically and behave identically in a browser tab, a web worker, and a Node process. Its package.json dependencies field MUST be either empty or restricted to an explicitly enumerated, pinned, pure-TypeScript allowlist; the hash and compression primitives of §8 are in-repo implementations (AR-130, AR-560), not third-party runtime dependencies. Every added dependency is a determinism liability and MUST be justified in review against AR-070.

Rationale: the decisive architectural win of this stack is that the server imports the same package the client does (AR-300). That only holds if the package has no environment-specific surface at all.

AR-020 The pnpm workspace dependency graph MUST be exactly: @everylastcity/client (React + Phaser web client; the same bundle wrapped by Tauri 2 for Windows/macOS/Linux/iOS/Android) → core; @everylastcity/server (Node + Fastify + Colyseus, see 05-multiplayer.md, 08-services-platform.md) → core; @everylastcity/cli (§11) → core; @everylastcity/plugin-sdk and @everylastcity/content (07-modding-content.md) → core; eslint-plugin-determinism (AR-085) is a development-only package that nothing imports at runtime. Nothing in core may depend on any of them. This MUST be enforced mechanically, not by convention: the workspace manifests declare the edges, and an ESLint no-restricted-imports zone plus a CI dependency-graph check MUST fail the build on any upward or lateral import. A relative import that escapes packages/core/src is a build error.

AR-030 @everylastcity/core MUST expose exactly three mutation entry points: createGame(setup, seed), applyOrder(order), and loadFrom(container). All other public surface MUST be read-only (views, queries, hashes, serialization) and MUST return values that are either primitives, frozen objects, or read-only typed-array views — never a live handle onto mutable state. UI and server code MUST NOT mutate simulation state by any other means.

applyOrder is the only path by which player intent reaches the simulation (10-turn-model.md TM-200, 13-command.md CM-2040, 14-victory.md VC-320). The Cascade and the Reckoning are not entry points and MUST NOT be callable with input: they are driven by the CloseOrders order record of TM-220 and run to completion inside that one applyOrder call, with no callback, no continuation, and no opportunity for a caller to intervene (TM-050). A host that wants to watch the Cascade reads the resulting observation stream (AR-290); it does not step the engine.

Rationale: three entry points is the whole reason the server can import the client's package. Making CloseOrders an ordinary order rather than a second entry point is what makes "the turn is atomic in the log" (TM-060) true by construction rather than by discipline — there is no API surface on which a host could have run half a Cascade.

AR-040 Rules behaviour is selected by recorded setup data, not by a mode. A game's behaviour MUST be fully determined by the tuple 01-game-rules.md GR-1460 names: the rules version, the 64-bit seed, and the setup parameters owned by 01-game-rules.md (GR-1480), 02-units-and-industry.md, 12-economy.md, 13-command.md and 14-victory.md. That tuple MUST be stored as data in one place — the SetupConfig of AR-150 — MUST be inside the hashed initial state (AR-560), and MUST be the sole input by which any rule branches.

The core MUST NOT carry a named mode enumeration, a preset identifier that changes behaviour, or any branch on the name of a preset. Presets are values of SetupConfig chosen at setup and immediately forgotten; two games created from the same parameter values MUST be byte-identical whether or not either was named after a preset. Optional rule modules (01-game-rules.md GR-1630's toggles, 11-combat.md §21, 14-victory.md §8's track catalogue) are additive and MUST appear in SetupConfig as their own parameters.

Rationale: a mode enumeration is a switch that grows a second meaning every release, and a rule that reads it is a rule nobody can evaluate from a save file. Parameters are inspectable, diffable, and hashable, so "why did these two games play differently" is answered by a diff rather than by reading the engine. It also removes the failure the old design invited — a preset whose behaviour drifts because a branch somewhere still tests the preset's name.

AR-045 Module granularity and contracts (AI-authorship constraint). The core MUST be composed of many small modules with explicit, documented contracts rather than few large ones. Normatively: each rules module MUST be a single file exporting pure functions over explicit input and output types; a rules module SHOULD NOT exceed ~300 lines, and MUST NOT exceed 500 without a recorded justification; each module's public functions MUST have their preconditions and postconditions stated in a doc comment naming the requirement IDs they implement; and cyclic imports between core modules MUST be a build error. A module together with its contract MUST be comprehensible without reading its neighbours.

Rationale: the implementation is written primarily by AI agents. Module size is a context-window constraint and a review-surface constraint, not a style preference; a 2,000-line rules file cannot be held, changed, and verified in one pass, and a silent cyclic import makes initialization order — and therefore determinism — depend on module resolution.

AR-050 The core MUST NOT perform any I/O (files, network, clock, environment) directly. Persistence, time, and logging are injected as plain TypeScript interfaces (GameStorage, Clock — for metadata timestamps and host-generated GameId values only, AR-150 — and SimLogger). Simulation results MUST NOT depend on any injected service: an implementation that returns different values on every call, or throws, MUST NOT change any hash. Injected services MUST NOT be reachable from any rules module (enforced by the AR-020 import zones): they are wired at the aggregate boundary only.

AR-055 Types are the enforced documentation (AI-authorship constraint). The core's TypeScript configuration MUST set at minimum strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noFallthroughCasesInSwitch, useUnknownInCatchVariables, isolatedModules, and verbatimModuleSyntax. In packages/core: any MUST NOT appear (including via implicit inference, catch bindings, and third-party type holes); @ts-ignore MUST NOT appear (@ts-expect-error MAY, and MUST carry a comment naming the reason and an issue reference); non-null assertion ! and type assertions as T MUST NOT appear except in branded-type constructors and canonical-decoder narrowing functions that validate before asserting. Entity identifiers (UnitId, CityId, PlayerId, TileIndex, OrderSeq, …) MUST be nominal branded types so passing a CityId where a UnitId is expected is a compile error. Orders, events, and rejections are discriminated unions with exhaustiveness checking (AR-245).

Fail loudly. Defensive defaults MUST NOT be used to paper over unexpected state: a violated internal invariant MUST throw a typed CoreInvariantError, never fall back to a plausible value. A silent fallback hides a defect until it reaches a player's save file, and under AI authorship it hides it from the only reviewer that reliably runs — the test suite.

AR-060 Plugins (AI players and world builders) MUST interact with the core exclusively through the Order/View contract of §4 and §6, hosted in the sandboxed WASM runtime specified in 07-modding-content.md and by the host requirements of AR-855. That host is the client or the server, never the core (AR-855): a pure package with no DOM, no Node built-ins and no ambient clock (AR-010, AR-085) cannot instantiate, sandbox, or meter a WebAssembly module. Native plugin loading MUST NOT exist. WASM is the plugin boundary and only the plugin boundary: the core itself is not a WASM module and MUST NOT depend on one for rules computation (AR-850).

Rationale: deep moddability of the AI and the world builder is a design goal, and the obvious way to deliver it — loading native code into the process — is unavailable to a product that runs in a browser tab and unacceptable to one that does not. A sandboxed, metered WASM module gives the same extensibility with a boundary the host can enforce, and it gives it identically on all six platforms.

2. Determinism contract

Determinism was nearly free in the withdrawn C#/.NET plan: the language had integer types, a fixed rounding model, and a compiler that would not let a double into an int by accident. TypeScript has one number type and it is IEEE-754 float64. Every guarantee in this specification — replays, async turn transfer, desync detection, anti-cheat, save integrity — rests on same-log-same-result, so this section specifies determinism as a mechanically enforced discipline. The enforcement of AR-085 and the CI job of AR-960 are Phase 0 deliverables and MUST be green before the first rules module merges — green on AR-960(a)'s seed fixture, since the corpus can only grow with the rules code that plays it. Retrofitting them is the one thing in this plan that genuinely cannot be done later.

AR-070 Given identical (initial state, order log, format/rules versions), @everylastcity/core MUST produce bit-identical final state and event streams on every supported runtime and at every worker count. The supported set is: Node 22 LTS on win-x64, linux-x64, linux-arm64 and macos-arm64; and the current release channels of Chromium, Firefox and WebKit on desktop and mobile (the same bundle inside a Tauri 2 webview counts as its platform's engine). This is the load-bearing invariant for saves, replays, async multiplayer, spectating, and desync detection.

Bit-identity MUST hold across JavaScript engines and engine versions, not merely across CPU architectures. The core therefore MUST NOT depend on any behavior ECMAScript leaves implementation-defined; AR-085 enumerates the constructs that carry such dependence and bans them.

AR-080 Numeric representation: no floating-point arithmetic in rules computation. All rules-affecting arithmetic MUST be exact. Exactly two numeric representations are permitted in simulation state and in rules code, and no others:

(a) Int — an integer-valued number. Class invariant: Number.isSafeInteger(v) holds, i.e. |v| ≤ 2⁵³−1. This is the representation the rules use, and every current rules document uses it exclusively: 01-game-rules.md GR-060, 02-units-and-industry.md US-030, 10-turn-model.md TM-100, 11-combat.md CB-2510, 12-economy.md EC-100 and 14-victory.md VC-110 each independently require integer-only arithmetic. It covers every rules quantity: strength and disorder, movement points, Works and Manpower, turn numbers, counts, costs, entity IDs, tile indexes, initiative scores, and per-mille fractions, which are the project-wide convention for a fractional quantity (GR-060, US-030, EC-120, VC-120, CM-120).

Division MUST NOT use /; it MUST use named helpers that state their rounding, so no rounding decision is ever implicit. The sanctioned set is intDivFloor (toward −∞, the project default per EC-110), intDivTrunc (toward zero, VC-140's convention), intDivRoundHalfUp, intMulDivFloor(a, num, den), and a intDivCeil implemented as intDivFloor(a + b − 1, b) for non-negative operands (EC-110, VC-140). Where two documents disagree on a rounding direction the calling requirement's stated helper governs; a helper MUST NOT be chosen by the implementer.

Ratio comparisons MUST be by cross-multiplication, never by division (EC-130, VC-130, CM-120): a / b ≥ c / d MUST be evaluated as a × d ≥ c × b. Intermediates MUST stay below 2⁵³; where a requirement's own bound is tighter it governs (CB-2510 bounds every combat intermediate below 2⁴⁷).

(b) Fixed — the project's single fixed-point representation, signed Q16.16. There is exactly one, available everywhere a genuinely fractional intermediate is unavoidable; a second scale MUST NOT be introduced.

Fixed currently has no consumers, and that is a deliberate state, not an oversight. Every rules document listed above forbids it in its own computations. It remains specified and implemented for two reasons: it is the only sanctioned answer if a future subsystem genuinely needs sub-per-mille resolution, and having one specified answer is what stops a subsystem inventing a second scale under deadline. A rules module MUST NOT introduce a Fixed value without the owning document stating why per-mille integers are insufficient. See open question 15.

Property Value
Scale factor 65 536 (2¹⁶)raw = real × 65536
Storage the raw value, as an Int, constrained to the signed 32-bit range [−2 147 483 648, 2 147 483 647]
Representable range [−32 768, +32 767.999985]
Resolution 2⁻¹⁶ ≈ 1.526 × 10⁻⁵ — ≈ 65× finer than the per-mille convention the rules documents use (65 536 raw units per unit value against 1 000 per-mille steps; 10⁻³ ÷ 2⁻¹⁶ = 65.536)
Canonical encoding one signed 32-bit little-endian integer (AR-620)
Rounding floor (toward −∞), always — one rounding mode for the whole type; no per-call-site choice exists
Overflow a typed DeterminismError throw (AR-055 "fail loudly"). Wrapping is banned; saturation is banned, because a saturated result is a plausible-looking wrong number that will survive review
Forbidden values NaN, ±Infinity, and −0 MUST NOT exist in any Fixed or Int; the canonical encoder MUST reject them (AR-620)

Rationale for the choice: a power-of-two scale makes conversion to and from Int a shift rather than a division, and constraining the raw value to int32 means the invariant is checkable in one instruction ((raw | 0) === raw), which is what makes a cheap runtime assertion affordable on every operation in debug builds. The range covers every quantity the rules produce with three orders of magnitude to spare.

Permitted operations on Fixed, and no others: fixedAdd, fixedSub, fixedNeg, fixedAbs, fixedMul(a, b), fixedDiv(a, b), fixedFromInt, the conversions fixedToIntFloor / fixedToIntTrunc / fixedToIntRoundHalfUp, and the total comparison fixedCmp. The conversion a call site uses MUST be the one the owning requirement names; Fixed's internal rounding is floor throughout, and the conversions differ only in how a value is projected back to Int. Applying a bare + - * / % ** operator, Math.round, or a comparison operator to a Fixed raw value is a defect and MUST be a lint error (AR-085); Fixed is a branded type (AR-055) so the compiler catches most of it and the linter catches the rest.

Exactness of the multiply/divide path. The product of two int32 raws reaches 2⁶², beyond number's exact integer range, so fixedMul and fixedDiv MUST NOT compute the intermediate in number. Two implementations MUST exist and MUST be proven equal:

  1. the fast path — exact 64-bit intermediate synthesized from 32-bit lanes using Math.imul and explicit carry, which allocates nothing and is what ships; and
  2. the reference path — the same operation in bigint, which is obviously correct and is used only in tests.

A property test (AR-950(k)) MUST assert the two agree over a generated domain that includes both range extremes, both signs, zero, and the powers of two either side of the lane boundary. Rationale: bigint is exact but allocates, and putting it on the hot path would cost the AR-770 replay-throughput budget; proving the fast path against it gets both properties.

Exact rationals, not decimal approximations. A fractional constant that is not exactly representable in binary MUST be expressed as an exact rational operation — intMulDivFloor(x, num, den) with integer numerator and denominator, and the rounding named by the helper — never as a decimal literal multiplied in. 12-economy.md EC-120 states the same rule for its own layer (floor(x × 3 / 4), never x × 0.75), and this requirement generalises it to the whole core.

Rationale: a decimal literal for a rational constant is wrong in a way no arithmetic test catches, because the arithmetic is working perfectly on the value it was given. The defect only appears as a one-off difference in a game outcome, months later, in a save file nobody can reproduce. The rational form costs nothing and makes the constant readable against the requirement that owns it.

Composition order is part of the rules. Where several per-mille multipliers apply to one value, they MUST be applied in the order the owning requirement states, with a floor after each step (02-units-and-industry.md US-040, 12-economy.md EC-140). An implementation MUST NOT reassociate, batch, or defer the divisions: floor(floor(a × p / 1000) × q / 1000) and floor(a × p × q / 1000000) differ, and choosing between them is a rules decision the core does not own.

Where floats are permitted. number used as a float is permitted only in rendering, layout, animation, audio, telemetry, and cosmetic UI code, all of which live outside packages/core. A float MUST NOT flow back into state by any path: view queries return Int/Fixed, and the client converts at the presentation boundary.

AR-085 Banned constructs and their mechanical enforcement. A shared ESLint configuration MUST govern packages/core, applied at severity error with --max-warnings 0. It MUST be delivered by the development-only workspace package eslint-plugin-determinism (AR-020) and applied through that package's exported flat configuration, so the rules and their default severities are versioned with the repository rather than assembled per project. It is a Phase 0 deliverable (AR-960). Within packages/core/src/rules/**, disabling any determinism rule by comment MUST itself be a lint error (eslint-comments/no-restricted-disable), so an exception requires a reviewed config change rather than a line an agent can add on its own.

The configuration MUST ban at minimum:

Category Banned Why it is not deterministic
Entropy Math.random, crypto, crypto.getRandomValues, crypto.randomUUID All randomness comes from the seeded PRNG the core owns (AR-350)
Clock Date, Date.now, new Date(), performance.now, process.hrtime Wall clock is not a rules input (AR-110)
Scheduling setTimeout, setInterval, queueMicrotask, requestAnimationFrame, async/await, Promise in rules paths Completion order is not reproducible (AR-145)
Environment process, globalThis, window, document, navigator, location, require, dynamic import() of rules code Ambient state differs per host (AR-050, AR-110)
Locale Intl.*, localeCompare, toLocaleString, toLocaleDateString, String.prototype.normalize Locale data and ICU versions differ per engine and per build (AR-100)
Float-valued Math Math.random, Math.pow, Math.sqrt, Math.cbrt, Math.hypot, Math.exp, Math.log/log2/log10, Math.sin/cos/tan/asin/acos/atan/atan2, Math.fround ECMAScript does not require these to be correctly rounded; V8, SpiderMonkey and JavaScriptCore are known to differ in the last bits. Math.pow(x, 0.5) and Math.sqrt(x) need not even agree with each other in the same engine
Float syntax any numeric literal with a fractional part or a non-integral exponent, anywhere under packages/core/src/rules/** (custom rule no-float-arithmetic) The one construct that most easily introduces a float by accident
Float coercion parseFloat, unary + on a string, Number.prototype.toFixed/toPrecision/toExponential, / and ** operators in rules paths Each produces or consumes a non-integral number
Iteration order for...in, Object.keys/values/entries over rules-bearing records, un-wrapped Map/Set iteration, Array.prototype.sort without a comparator See AR-090
Identity WeakMap, WeakSet, WeakRef, FinalizationRegistry, structuredClone, object-identity comparisons that reach state Address- and GC-dependent behavior (AR-110)
Serialization JSON.stringify of any state object for hashing or equality Output depends on property insertion order (AR-620)
Types any, @ts-ignore, unchecked as, non-null ! AR-055

Permitted Math members (exact for the Int and Fixed domains, and specified exactly by ECMAScript): Math.floor, Math.ceil, Math.round, Math.trunc, Math.abs, Math.sign, Math.min, Math.max, Math.imul, Math.clz32. Uses of Math.min/Math.max in rules paths MUST be guarded by the Int/Fixed invariant, since both propagate NaN and distinguish −0 — values AR-080 forbids from existing in the first place.

Runtime backstop. Lint catches source constructs; it does not catch a banned construct reached through a dependency or a dynamically built accessor. The test harness MUST therefore install a determinism trap before the core is imported, replacing Math.random, Date.now, the Date constructor, performance.now, crypto.getRandomValues, and Intl with functions that throw. Every test in the core suite runs under the trap. A construct that escapes both the linter and the trap is then caught by AR-955 or by the cross-platform job of AR-960 — three independent layers, because a determinism defect that reaches a shipped save is unrecoverable for the affected game.

AR-090 Deterministic collection iteration. All iteration that can influence state MUST occur in a defined, stable order — by entity ID ascending unless a rule specifies otherwise.

(a) Entity collections are dense arrays indexed by ID (AR-150, AR-710); iteration is ascending index. This is the normal case and requires no wrapper.

(b) Map and Set MAY be used as lookup structures in rules code but MUST NOT be iterated there. Where iteration over a keyed collection is genuinely needed, it MUST go through the sanctioned OrderedMap / OrderedSet wrappers, which maintain an explicitly sorted key array and expose iteration only over it. The total order is: numeric ascending for numeric keys; for string keys, ordinal UTF-16 code-unit order via < — never localeCompare (AR-100).

(c) Bare object keys MUST NOT be iterated: for...in, Object.keys, Object.values, Object.entries, and object spread over a rules-bearing record are banned by AR-085. JavaScript's own property-order rules (integer-like keys first, ascending, then string keys in insertion order) are specified, but they make ordering depend on how an object was built, which is exactly the coupling this requirement removes.

(d) Every comparator used in a rules path MUST define a total order: it MUST be antisymmetric and MUST NOT return 0 for distinct elements. Where the natural sort key ties, the entity ID is the mandatory final tiebreak. Array.prototype.sort is stable since ES2019, but a partial comparator would still make the result depend on the pre-sort arrangement, and this rule removes the question rather than relying on stability.

(e) Enforcement is a property test, not only a lint rule. A shuffle harness MUST exist: a test mode that constructs a semantically identical initial state with entities created and inserted in a PRNG-shuffled order, replays the same order log, and asserts identical TurnHash and StateHash sequences (AR-950(l)). This catches insertion-order dependence that no static rule can see.

AR-100 String and locale handling. Simulation code MUST NOT use locale-sensitive comparison, formatting, or parsing. String comparison affecting state is ordinal (UTF-16 code unit) and case-sensitive, using ===, <, >. Content keys are already constrained to a narrow ASCII grammar by 02-units-and-industry.md US-070 (^[a-z][a-z0-9_]{1,31}$), and 02-units-and-industry.md US-050 fixes iteration over unit definitions as ascending lexicographic id — both of which this requirement's ordinal comparison implements exactly. Because JavaScript strings are UTF-16 and Unicode normalization is not applied implicitly, two visually identical keys outside that grammar could compare unequal; the content loader (07-modding-content.md) MUST therefore reject any key that is not already in Unicode Normalization Form C, and the core MUST NOT normalize keys itself. Player-authored free text (city and unit names) is state and is stored and hashed as authored — the core MUST NOT case-fold, trim, or normalize it.

AR-110 No ambient inputs and no identity-derived values. The simulation MUST NOT read wall-clock time, environment variables, machine identity, memory addresses, or object identity. Specifically: no Date/performance reads (AR-085); no WeakMap/WeakRef-keyed behavior that reaches state; no reliance on object reference identity, allocation order, or garbage-collection observables; no iteration order derived from how an object was constructed (AR-090). Timestamps in saves are metadata only and MUST be excluded from state hashes.

AR-120 Deterministic parallelism. Any stage run across web workers or Node worker_threads (§10) MUST produce results independent of scheduling: pure functions over disjoint domains, with results collected into an array indexed by shard number and reduced in shard order — never in completion order. Shared mutable memory MUST NOT be used in rules paths; a SharedArrayBuffer MAY carry read-only fanned-out data, and Atomics MUST NOT be used to coordinate anything a rule observes. State mutation remains single-threaded per game (AR-730). CI MUST replay the same log at 1 worker and N workers and require identical hashes (AR-960).

AR-130 State digests. The core MUST compute three digests over the canonical encoding of AR-620:

Digest Algorithm Computed Purpose
StateHash SHA-256 Reckoning step 9 (10-turn-model.md TM-1930, TM-2400) Save integrity, replay verification, cross-platform agreement. Stored in snapshots and the save manifest
TurnHash 64-bit XXH3 Reckoning step 9 Cheap per-turn desync comparison between peers (AR-530)
ActivationOrderHash 64-bit XXH3 CloseOrders, before any unit acts (TM-2370) Pre-Cascade desync detection over the frozen activation roster
AdvisoryHash 64-bit XXH3 Reckoning step 7 Detects divergence in what players are told — Attention Events and Perceptible Events — without coupling it to the rules hash (13-command.md CM-190, 01-game-rules.md GR-1670)

AdvisoryHash MUST be versioned by advisoryVersion (13-command.md CM-2080), MUST NOT contribute to StateHash, and a change to it alone MUST NOT invalidate a replay. Its purpose is that a defect in the advisory layer is caught in CI as a hash difference rather than discovered as a player report that the Dispatch stopped mentioning something.

ActivationOrderHash MUST be computed over the frozen roster encoded as, for each index in ascending order, the triple (creationSequence, initiative, ownerIndex) (TM-2370), MUST be carried on its own channel, and MUST NOT contribute to StateHash, because the roster is derived state (TM-320, TM-2390). Its value is that it catches the most likely class of desync in this turn model — an initiative term applied differently, or a sort without a total comparator — before any state has diverged, which names the bug rather than merely reporting that two hashes differ three turns later.

All three MUST be synchronous, pure-TypeScript, in-repo implementations operating over typed arrays, validated in CI against the published test vectors for each algorithm. They MUST NOT be taken from WebCrypto (crypto.subtle is asynchronous, is unavailable in insecure browsing contexts, and is banned by AR-085) nor from a platform-specific native binding, because the browser, the worker, the Node server and the CLI must all produce the same bytes from the same code. The 64-bit lanes XXH3 requires MUST be synthesized from 32-bit lanes with Math.imul and explicit carry, as in AR-350. Digest cost under this constraint is materially higher than a native SIMD primitive; AR-760 budgets it honestly.

AR-135 coreHash — the keyed, counter-based hash. The core MUST expose a positionally addressed hash coreHash(seed, ...inputs) → u64, a pure function of the 64-bit game seed and an ordered tuple of Int inputs, carrying no cursor and no state. 11-combat.md CB-2560 requires exactly this function, and CB-800 is its principal caller: a Clash's single random value is coreHash(seed, turn, initiatorSequenceNumber, attackOrdinal), not a stream draw.

Normative constraints:

(a) It MUST be built on the same in-repo 64-bit primitive family as AR-130 and MUST be versioned with the randomness version of AR-350; a change to either changes every game's outcome.

(b) It MUST advance no state, allocate nothing, and be safe to call concurrently and off the simulation thread — 11-combat.md CB-2610 and CB-2590 require a preview path evaluating 100,000 candidate attacks per second without perturbing a single value.

(c) Callers MUST derive a bounded value by multiply-shift, never by modulo (CB-840): from the low 32 bits u, a value in [0, N) is floor(u × N / 2³²), computed in at least 42 bits of exact integer precision (CB-850). Modulo reduction is banned for the same bias reason as AR-360.

(d) The input tuple MUST be unique per addressed outcome. A caller MUST document why its tuple is unique; CB-830 does so by pairing the initiating unit's creation sequence with a per-turn attack ordinal.

Rationale: this is the largest determinism win in the specification set and it belongs in the architecture rather than only in the combat document. A stream cursor makes every draw a function of how many draws preceded it, so a preview perturbs the game, a skipped combat shifts every later one, and a replay must reproduce the exact call sequence. A positionally addressed hash makes each outcome a function of the identity of the engagement alone: the preview shown during Orders and the resolution computed during the Cascade address the same cell by construction, and the AI can evaluate as many hypotheticals as it likes. It also removes the whole draw-discipline burden of AR-380 from the one subsystem that would have carried it hardest.

AR-140 Totality. applyOrder MUST be total: every input either applies (emitting events) or returns a typed rejection (AR-320). Rejections MUST be returned values in a discriminated result union, never thrown — a rejection is an expected outcome and control flow by exception would make the rejection path untyped. A thrown exception from applyOrder is by definition a defect: it means an internal invariant was violated (AR-055 CoreInvariantError), and it MUST fail CI fuzzing (§14).

AR-145 The core is synchronous. No function on the core's public surface, and no function in any rules module, may be async, return a Promise, or await. createGame, applyOrder, loadFrom, every view query, and every hash MUST complete synchronously on the calling thread.

Rationale: this is a structural determinism guarantee, not a style preference. If any part of order resolution could suspend, resolution order would depend on microtask and I/O scheduling, which differs between engines, between Node and the browser, and between runs on the same machine. Making the core synchronous means that class of nondeterminism cannot be written. Asynchrony belongs at the boundary: storage, transport, and plugin invocation are the caller's concern, and a plugin's asynchronous result enters the core only as an already-materialized order (AR-855).

3. State model

AR-150 The authoritative state is a single Game aggregate. All entities are addressed by dense integer IDs; references between entities are by ID, never object reference, so the state graph serializes canonically and no ordering can be derived from allocation (AR-110). IDs are nominal branded types (AR-055).

The integer widths named in the table below (u8, u16, u32, …) are the canonical encoding widths of AR-620 and the range invariants the core enforces on every write; they are not TypeScript types. In memory, bulk per-tile and per-unit data lives in typed arrays of the stated width (AR-710) and everything else is an Int (AR-080) carrying an asserted range. Sixty-four-bit values (Seq, Seed, PRNG state, TurnHash) exceed Int's safe range and MUST be carried either as bigint or as an explicit {hi, lo} pair of unsigned 32-bit Ints, with the choice fixed per field by the canonical schema and never varying by call site — AR-350 fixes the PRNG's representation, AR-260 the log sequence's.

GameId is a UUIDv7, which embeds a timestamp and therefore MUST be generated by the host through the injected Clock (AR-050) and passed into createGame; the core MUST NOT mint one. Top-level composition — each row names the document that owns the field's semantics, and this document owns only its representation, its range invariant and its place in the canonical encoding:

Entity Key fields (normative minimum) Semantics owned by
Game GameId (UUIDv7, host-generated), FormatVersion, RulesVersion, commandLogicVersion, combatLogicVersion, ContentRefs (unit set by key/version/SHA-256), SetupConfig, VictoryConfig, PluginRuntimeConfig (AR-857 — present iff the game ran any plugin), Seed, RngState[] (the setup streams of §5), TurnNumber, Phase, CreationCounter, Map, Players[], Cities[], Units[], Formations[], Groups[], Postings[], Consignments[], Requisitions[], SealRecords[] this document; 10-turn-model.md TM-090, TM-320
SetupConfig The map parameters of 01-game-rules.md GR-1480, plus the economy, roster, command and victory parameters those documents own (AR-040). Stored as values, never as a preset name GR-1460, GR-1480
VictoryConfig The enabled Track set and each Track's parameters, Vigil lengths, Ebb schedule, and the generation-time schedules of 14-victory.md VC-170 (Ascendancy rotation, Warrants, Sealed Orders, Horizon jitter), stored and never recomputed 14-victory.md §4, §8
Map Width, Height (each in [16, 2048], GR-120), Topology (flat | cylinder | torus, GR-190), tile SoA arrays (§10), Provenance (generator plugin id/version/options/seed, or authored map/scenario ref) 01-game-rules.md §2
Tile (SoA) TerrainId (u8, the eleven values of GR-260; immutable per GR-270, see AR-185), Installation indexes, River-edge bits per edge (GR-480), sparse CityId 01-game-rules.md §3, §4
Player PlayerId (u8; 0 reserved for the World/Neutral owner), seat index, controller kind (metadata), manpower pool, Standing, Tenure, Broken, Reduced, Seatless, placing, consecutiveMissedTurns, substitution state, commit marker for the current turn, Doctrines 12-economy.md EC-160; 14-victory.md §5, §14; 10-turn-model.md TM-320
City CityId, tile, Name, Owner, foundingOwner, integration, unrest, revoltCountdown, population, popProgress, warDamage, warDamageProgress, yard, unsettled, emergencyLevyCooldown, industryLevel, worksExpansionLevel, traits, homelandClaimants, per-player loyalty map, production queue, Landmark/Seat/Work-Site markers 12-economy.md EC-160; 02-units-and-industry.md §5, §7; 14-victory.md §8
Unit UnitId (u32 index + u16 generation), TypeId, Owner, tile, creationSequence, strength, disorder, xp, digIn, shaken, Stance + Stance override, age (Forming Up), entrenchment, reaction capacity spent, movementRemaining, banked movement, worksPaid, manpowerPaid, supplyGrade, isolatedTurns, order stack (three levels), Fallback Posture, Sanction values, HostUnitId?, optional Name 11-combat.md CB-100; 10-turn-model.md TM-320; 13-command.md §3–§5; 12-economy.md EC-160
Knowledge (per player) The Terrain Record of 01-game-rules.md GR-1140 (terrain, Installations, River edges, city presence, observation turn, per explored tile) and the Contact Register of GR-1210 (per Contact: subject sequence number, lastTile, lastSeenTurn, grade, trackTurns, step, admitted attributes) 01-game-rules.md §9, §10
SealRecord Per Seal, per live player: Standing, Tenure, Seatless/Broken/Reduced state, and per enabled Track the raw progress, threshold, ratio, Vigil counter and visibility band (VC-250) 14-victory.md §3
MapAnnotations Per-player labels and waypoints — carried in the save container as player metadata, not in simulation state 04-ui-ux.md

AR-155 Player knowledge is authoritative state, not a derived view. The Terrain Record and the Contact Register MUST be part of the Game aggregate, MUST be serialized with it, and MUST contribute to StateHash (01-game-rules.md GR-1660). They MUST NOT be reconstructed at load time from the current world.

Two consequences bind this document. First, knowledge is not recomputable: GR-1340's sustained-observation tracks and GR-1350's exclusion sets depend on the history of what was observed and when, which no snapshot of the present world contains. Second, the fog code is therefore covered by the ordinary determinism apparatus — a divergence in visibility trips at the state hash in CI (AR-960) rather than in a player's game, which is the only place it can be found cheaply. AR-480's per-player bitsets are the storage of the knowledge level; they are hashed state, not a cache.

Rationale: it is tempting to treat knowledge as a projection because it looks like one, and the temptation is expensive. A projection cannot carry history, and every interesting rule in 01-game-rules.md §10 is about history. Hashing knowledge also means the single most desync-prone subsystem in the engine is the one most cheaply verified.

AR-160 PlayerId 0 MUST be the reserved World/Neutral owner. Neutral cities (01-game-rules.md GR-990's neutralDefence and the unowned cities GR-1570's setup leaves on the map) are owned by player 0. Player 0 MUST NOT issue orders, MUST NOT hold a seat, MUST NOT be evaluated by any Track (14-victory.md), and MUST NOT be counted as a live player anywhere. Units it owns, where a ruleset gives it any, appear in the activation roster like any other owner's (10-turn-model.md TM-1140) and MUST resolve under a fixed, configured Posture rather than under any decision procedure.

AR-170 Presentation-only data (player colours, HUD layout, camera position, playback pacing and step-through state, Dispatch read state, the Empire Ledger's sort order, forecast overlays) MUST NOT be part of simulation state or hashes. It is stored in the container's metadata section (§8) or client settings (04-ui-ux.md). The documents that own those surfaces state the same exclusion for their own data — 12-economy.md EC-170, 13-command.md CM-180, 10-turn-model.md TM-320, TM-2330 — and this requirement is the architectural form of it: anything a client may improve between releases without invalidating a saved game MUST be outside the hash.

AR-180 Map features are tile data; supply is derived geometry. Neither is an entity. Installations (01-game-rules.md §4 — roads, forts, airfields, bridges) MUST be stored as tile data in the SoA arrays of AR-710, never as units, while retaining their construction, ownership and destruction rules. Supply MUST be a computed reachability field over the map (12-economy.md §13's Reach), never a unit, a cargo, a shipment, or a routed entity; there is no in-transit ledger and no convoy state to serialize.

Rationale: both are places a simulation naturally grows entities it does not need. A road modelled as a unit acquires an activation slot, a combat profile and an owner's attention; a supply shipment modelled as an entity acquires a position, an ETA, and a rule for what happens when its destination dies. Storing the first as tile data and computing the second on demand deletes those questions rather than answering them, and it is why 12-economy.md EC-180 can hold the line at two resources with no third ledger.

AR-185 Terrain is immutable, and the encoding MUST make that structural. Every tile carries exactly one TerrainId from the eleven-value vocabulary 01-game-rules.md GR-260 owns normatively. 01-game-rules.md GR-270 forbids terrain from changing during play: no unit, order or event may convert one terrain type to another, and everything that modifies a tile does so as an Installation.

Architectural obligations that follow: the TerrainId array MUST be written once at initial-state materialization and MUST NOT be mutated thereafter; the core MUST expose no API that writes it; there MUST be no TerrainChanged event (AR-280 does not define one); and every per-terrain lookup table the core consumes MUST be indexed by the full eleven-value enum, because the movement-cost table of GR-290 and the terrain-defence procedure of GR-380 carry a row for every value. A content loader MUST reject a terrain set that does not define all eleven.

Rationale: immutability is what makes the Terrain Record of GR-1150 permanent, which is what makes exploration a thing a player can finish. It is also worth a great deal here: an immutable array can be shared read-only across workers with no synchronisation, excluded from incremental snapshot deltas, and hashed once at setup rather than every turn. A design decision made for legibility pays for itself three times in the architecture.

AR-190 Every unit-type capability referenced by the core MUST be data-driven from the content bundle (02-units-and-industry.md): the movement, vision and domain fields of US-120, the closed capability-flag vocabulary of US §3, the combat stats 11-combat.md CB-150 consumes, and the costs 12-economy.md EC-280 consumes. No code in the core may branch on a unit's id, name, or display text — 02-units-and-industry.md US-060 requires this and requires it to be enforced by a CI lint over the core package; AR-085's configuration MUST carry that lint.

Rationale: this is the requirement that makes custom unit sets a real feature rather than a promise. One if (unit.id === 'submarine') in the core ends it — from that moment a modder's new unit can never behave like a submarine, and the promise has silently become a lie. It is mechanically checkable, so it is checked.

AR-200 Two lifecycles exist and MUST NOT be conflated. The session lifecycleSetup → Lobby → Active → Finished — describes a game's existence as an object the platform manages: created, being joined, being played, over. Map generation and initial-state materialization occur at the Lobby → Active transition (the pre-launch container profile that carries a game through sign-in is AR-567).

The turn phase is the authoritative simulation state value that 10-turn-model.md TM-090 owns: Setup, Orders, Cascade, Reckoning, Ended. It MUST be an explicit field on the Game aggregate and MUST contribute to StateHash (TM-090, TM-320). Every order the core accepts MUST name the turn it is issued for and MUST be rejected if the game is not in the Orders phase of that turn (TM-260, AR-320).

There is no per-player turn phase, no current-player index, and no per-player sub-state within a turn. A turn belongs to every player at once (TM-330); a player's position in the turn is a commit marker (Committed | TimedOut | Absent | Substituted, TM-300), not a phase.

AR-205 Setup MUST complete before turn 1's Orders phase opens. 10-turn-model.md TM-070 requires turn 1 to have a full Orders phase and requires every starting unit to carry a complete order stack (13-command.md CM-080) before that phase opens. The core MUST therefore materialize the initial state — map, cities, starting units, each unit's default Posture, Sanctions and Fallback Posture, and each player's initial knowledge — inside createGame, and MUST NOT rely on a first-turn special case to finish it.

There is no turn-one movement lock and no turn-one order restriction of any kind. Turn 1 differs from turn 40 only in that no order has yet been persisted (TM-280) and no unit has yet accrued age, entrenchment or veterancy.

Rationale: a lifecycle with a special first turn is a lifecycle with two rule sets, and the second one is exercised once per game and therefore tested least. Doing the whole of setup in createGame means the invariant "every unit has a valid order stack" holds from the first hash onward and can be asserted continuously (AR-945).

AR-210 The turn is Orders, Cascade, Reckoning. The core MUST implement exactly the three-phase turn of 10-turn-model.md TM-020, in that order and no other. It MUST NOT implement sequential per-player turns, a per-player upkeep, an interactive activation queue, or any structure in which one player's actions block another's ordering.

Structurally, and binding on the core's shape:

(a) Orders accepts order records through applyOrder and mutates only per-player state (TM-350). No unit moves, no combat occurs, no city changes hands, and no fog lifts or falls (TM-340).

(b) The Cascade takes no input (TM-050), is a pure function of the state at the close of Orders plus the ruleset (TM-040), and runs to completion inside the applyOrder call that carried CloseOrders (AR-030).

(c) The Reckoning takes no input, executes the fixed step list of TM-1930 with no interleaving (TM-180), and is the only place a game may end (TM-1990).

Together, (b) and (c) MUST be a pure function of the committed state at the close of (a). That single property is what every other guarantee in this document reduces to, and it MUST be testable in isolation (AR-950).

AR-220 Orders are policies evaluated at activation, not commands executed on submission. applyOrder MUST record intent; it MUST NOT resolve it. An order record's effect during the Orders phase is confined to writing the issuing player's own state — an order-stack level, a Stance, a production queue entry, a Posting weight (TM-350). Movement, combat, capture and reaction happen only during the Cascade, and only through the activation procedure 10-turn-model.md TM-1320 owns.

The core MUST NOT store, transmit, or resolve an order as a recorded sequence of steps (TM-1360, 13-command.md CM-340). A stored route is a cache of a policy's most recent evaluation and MUST be recomputable from the policy and the world; nothing may depend on it having been stored.

Rationale: this is the largest single change from an interactive turn engine and it simplifies rather than complicates the architecture. Because submission cannot resolve anything, the Orders phase needs no locking, no ordering guarantee between players, and no rollback: it is a set of independent per-player writes. All of the engine's complexity moves into one pure function that runs with nobody watching, which is exactly the function that is cheapest to test, replay and verify.

AR-225 Order records from different players MUST commute. An order record MUST only be able to mutate state belonging to its issuing player, so that any two records from different players commute regardless of arrival order (10-turn-model.md TM-240). This MUST hold structurally, enforced by the shape of the state and of the apply functions, not by convention or review.

A player's own successive records resolve last-write-wins in ascending log sequence number (TM-230), which MUST be the only interleaving effect the log has.

Rationale: this is what makes concurrent ordering honest — no race to submit, no advantage to a 20 ms connection over a 180 ms one. Stating it as a structural property rather than a hope is what lets a test prove it: shuffle a turn's log across players, preserving each player's internal order, and no hash may move (AR-950, TM-2360 hazard 7).

AR-230 The Reckoning pipeline. End-of-turn bookkeeping MUST be encoded as an explicit, ordered, individually testable pipeline whose step list is 10-turn-model.md TM-1930's. This document owns the pipeline machinery; TM-1930 owns the order; the named documents own the formulas. Three architectural obligations bind it:

Obligation Requirement
No interleaving Each step MUST run to completion before the next begins (TM-180). A step MUST NOT be split so that part of it runs after a later step, and an optimisation that fuses two steps MUST be proven to produce identical state
No spatial effects No Reckoning step may move a unit, initiate combat, or change tile ownership (TM-1970). A step needing one MUST instead set state the next Cascade acts on. The core MUST make this checkable: the instrumented build of AR-955 MUST assert that no tile occupancy or ownership field is written during the Reckoning
Canonical iteration Per-player steps process players in ascending seat index; per-city steps in ascending city index; per-unit steps in ascending creation sequence; per-tile steps in ascending tile index (12-economy.md EC-150, 14-victory.md VC-180). AR-090's OrderedMap/OrderedSet wrappers exist for the cases where the collection is keyed rather than dense

Three sub-pipelines are owned elsewhere and nested inside TM-1930's steps, and this document MUST NOT restate their contents: the Ledger phase of 12-economy.md EC-1990 (18 steps, TM-1930 steps 2–4), the combat aftermath of 11-combat.md (step 1), and the Seal of 14-victory.md VC-230 (14 steps, step 8). Where a step's owner changes its internal order, this document is unaffected; where TM-1930's relative order changes, RulesVersion bumps (TM-2450, AR-880).

Rationale for owning the machinery and not the list: the previous revision of this section restated another document's phase order and immediately acquired four unresolved questions about it. Ordering is rules. What the architecture genuinely owns is that the ordering is explicit, that it cannot be interleaved by an optimisation, and that the iteration inside each step is canonical — three properties that are testable here and meaningless there.

4. Orders and Events

AR-240 Clients (human UI, AI plugins, server relays) submit Orders — declarations of intent, accepted only during the Orders phase of the turn they name (10-turn-model.md TM-260). The core validates each order against current state and either rejects it with a typed reason (AR-320) or applies it, recording the intent.

Events describe state changes and are emitted by the Cascade and the Reckoning, not by order submission. Events are the only way state changes are described to the outside; no consumer may infer state by other means. Two host-issued order kinds — CloseOrders and, before it, TurnTimeout — are the boundary: CloseOrders runs the Cascade and the Reckoning to completion and returns their whole event stream (AR-030).

AR-245 Orders, events, and rejections are exhaustively-typed discriminated unions (AI-authorship constraint). Each of the three MUST be a TypeScript discriminated union keyed on a string-literal kind field; the union's members MUST be enumerated in one place; and the kind type MUST NOT be widened to string. Every switch over one of these unions in the core MUST be exhaustive and MUST end in a default branch calling assertNever(x: never), so adding a member without handling it everywhere is a compile error rather than a silent fallthrough (noFallthroughCasesInSwitch, AR-055). The kind literals are the wire contract: they MUST equal the order names of AR-270 and the event names of AR-280 exactly, character for character, and a mapping layer between internal names and wire names MUST NOT exist.

Rationale: the single most likely defect in AI-authored rules code is a new order or event type handled in the four places the agent noticed and silently defaulted in the fifth. Exhaustiveness turns that from a runtime behavior change — which only a fixture that happens to exercise the path would catch — into a build failure.

AR-250 The canonical log is the order log: initial state plus an append-only order log is the game. The Cascade and the Reckoning MUST NOT be written to it and MUST be recomputed on replay from the turn record and the prior state (10-turn-model.md TM-310, TM-2030). Events, hashes, per-player observation streams and Seal records are therefore all derived; a cached event stream MAY be stored (§8) but is never authoritative.

Rationale (TM-310's, and it is worth restating where the container format is specified): logging a derived result lets a divergence hide inside the log instead of tripping on it. If the log carried the Cascade's outcome, a replay that computed a different outcome would overwrite reality with the recording, and the bug would surface three turns later as an inexplicable state. Recomputing means a divergence fails immediately, at the hash comparison, where it is findable.

AR-255 The turn record. The log MUST be structured as a sequence of turn records, each consisting of (10-turn-model.md TM-300): the turn number; every order record accepted during that turn's Orders phase, in ascending Seq; and, per player, exactly one terminal commit marker — Committed, TimedOut, Absent, or Substituted.

A turn record MUST be atomic in the log (TM-060): a reader MUST be able to tell that a turn closed without replaying it, and the turn number, phase and per-player commit markers MUST be recoverable from the log alone (TM-2470). A partial turn record — orders present, no CloseOrders — MUST be legal and MUST describe a game currently in its Orders phase.

AR-260 Order envelope: { Seq (u64, authority-assigned, gapless), Turn, IssuingPlayer, Issuer, OrderKind (the AR-245 literal, encoded as a u16 catalog index), Payload (schema-versioned), ClientTag (UUID for idempotent retry), ChainHash }. ChainHash = XXH3(previous record's ChainHash ‖ this record's canonical bytes), making the log tamper-evident. Receipt timestamps, deadlines, reserve-bank state and transport metadata live outside the hashed record — 10-turn-model.md TM-190 and TM-2430 require that no wall-clock value reach the core and that a replay never consult one.

Turn is mandatory on every record, not merely conventional: TM-260 requires a record naming any turn but the current one to be rejected rather than queued, which is only checkable if the record carries the turn it was issued for.

Seq is a u64 and MUST be carried as a bigint in TypeScript and as a decimal string in the canonical JSON projection (AR-620) — never as a bare JSON number, which loses precision above 2⁵³ and would make an exported log silently unfaithful. The same rule binds every u64 field in every projection.

Issuer is an enum — Seat (the seat's own controller, human or AI plugin) or Authority(reason) where reason ∈ {Timeout, AiSubstitution, PluginFallback, Host, …} — recording that the order was generated on the seat's behalf by the authority rather than by its controller: deadline expiry (10-turn-model.md TM-550), AI substitution (TM-580, 05-multiplayer.md MP-1010), AI-plugin fault fallback (06-ai.md AI-240), and the host-issued CloseOrders and TurnTimeout of TM-220. Issuer is part of the canonical hashed bytes and MUST be preserved through replay and export, but the core MUST resolve an order identically whatever its Issuer — provenance is audit and display data, never a rules input. Non-order authority actions (rollback, mode switch) are not representable here; they live in the audit log of AR-563.

AR-270 The order catalog. The union is enumerated in exactly one place (AR-245) and its members are contributed by the documents that own their semantics. This table owns the encoding and fixes the wire contract; an order kind that appears here and not in its owning document, or the reverse, is a defect to be fixed rather than a synonym to be mapped.

Owner Order kinds
10-turn-model.md TM-220 (nine kinds) SetOrder, SetStance, ClearOrder, CommitOrders, RevokeCommit, TurnTimeout, CloseOrders, SubstitutePlayer, ResumePlayer
13-command.md The payload vocabulary carried inside SetOrder — Postures (CM-330), Sanctions (§5), Task end conditions (CM-260) — plus the management kinds for the objects §6–§8 define: Doctrines, Formations, Groups, Postings, Consignments, Requisitions, Fallback Posture, and route-policy settings
12-economy.md Production-queue management, Emergency Levy, disband, razing, Depot emplacement, Cadre assignment, Works Expansion
14-victory.md VC-320 (six kinds) DesignateSeat, DesignateWorkSite, SelectCharter, CastVerdictVote, Concede, ClaimWarrant
this document RenameCity, RenameUnit — free-text state (AR-100) with no rules effect

Two structural properties bind every member, whatever its owner. Each MUST be issuable only during the Orders phase of the turn it names (TM-260) — TurnTimeout and CloseOrders are host-issued and close that phase. And each MUST mutate only its issuing player's state, so that records from different players commute (AR-225, TM-240); a proposed order kind that cannot satisfy that is a proposed order kind that needs redesigning, not an exception.

Three kinds of thing are deliberately absent from the catalog and MUST NOT be added: an order that resolves movement or combat at submission (AR-220); an order that navigates, focuses, or defers a unit's activation, because there is no interactive activation to navigate; and an undo of an applied order (AR-330).

AR-275 Order log vs. client state — the boundary is normative. The canonical log carries only inputs that can change simulation state. Everything else is client-local, MUST NOT be an order kind, MUST NOT enter the log, and MUST be reconstructible or discardable without affecting any hash:

Client-local Why it is not an order
Camera, selection, map filters, panel state Never observed by a rule
Uncommitted staging of orders a player is composing (04-ui-ux.md owns staging) It reaches the core only on submission; TM-470 requires it to survive leaving and re-entering a phase, which is a client obligation
Playback pacing, clustering, skip and step-through state (TM-2330) Playback is presentation of a finished result (TM-2240)
Rolling planning staging for turn T+1 (TM-2550) TM-2570 requires it to be rejected under TM-260 if submitted before the next Orders phase opens
Presence — who is connected, who has committed, time remaining (TM-440) Ephemeral, non-authoritative, and content-free by construction; it carries no fact about the game world
Dispatch read/dismiss state, Attention Event acknowledgement 13-command.md CM-180 excludes it from the hash
Forecast overlays, supply overlays, Empire Ledger sort order (12-economy.md EC-170) Recomputable from hashed state alone

Rationale: the line is "could a replay produce a different game without it". A replay applies logged orders in Seq order and never consults a camera, a scroll position, or whether a player read their Dispatch. Logging those would add un-replayable, rules-inert records to the canonical log and would grow the one file whose bytes determine the game. The one genuinely subtle case is staging, and it resolves cleanly: staged orders are not orders until they are submitted, which is exactly why TM-360 can promise free revision with no record beyond the log.

AR-280 Events. Events MUST be fine-grained, self-describing, and emitted only by the Cascade and the Reckoning. The catalogue below is the normative minimum; the owning document's semantics govern, and this document owns only that each is a distinct, exhaustively-typed member of the AR-245 union.

Phase / source Events
Cascade — activation (TM-1320, TM-1330) ActivationBegan, UnitMoved (per tile entered, in tile-entry order), UnitHalted {reason}, OrderFellBack (TM-1400), ActivationSkipped {reason}, ActivationEnded
Cascade — combat (11-combat.md) ClashResolved {specification, r, losses, disorder applied}, ReactionFired, BombardmentResolved, Disengaged, UnitBroke, UnitRouted, UnitDestroyed, Encircled
Cascade — territory CityCaptured, WorkCaptured, InstallationBuilt, InstallationDestroyed
Cascade — knowledge (01-game-rules.md §9, §10) TileObserved, ContactGained, ContactUpgraded, ContactLost
Reckoning — combat aftermath (step 1) DisorderRecovered, DisruptionSet, DisruptionCleared, VeterancyAwarded
Reckoning — economy (steps 2–4, 12-economy.md EC-1990) SupplyGradeChanged, AttritionApplied, RepairApplied, UnrestChanged, SabotageFlagged, IntegrationChanged, RevoltExecuted, WorksAccrued, QueueItemCompleted, AwaitingLevy, LevyPaid, ManpowerCapped, PopulationAdvanced, UnitCommissioned
Reckoning — clocks and command (steps 5–7) AgeAdvanced, EntrenchmentChanged, OrderEndConditionMet, RequisitionShortfall, AttentionEvent
Reckoning — victory (step 8, 14-victory.md) SealRecorded, TrackProgress, VigilStarted/VigilBroken, EbbStep, StatusChanged {Broken, Reduced, Out of Contention}, GameEnded
Reckoning — close (step 9) TurnEnded {TurnHash, StateHash}

There is deliberately no TerrainChanged event: terrain does not change (AR-185, 01-game-rules.md GR-270). Adding one would be a rules change in another document's territory.

AR-290 Every event carries: a monotonic EventSeq, a causal reference (the activation index within the Cascade, or the Reckoning step), and a visibility set — the set of players entitled to see it, computed by the fog rules (§6) at emission time. View filtering (AR-420) is a pure function of the visibility set.

AR-295 The observation stream is compacted, and compaction is a security property. The stream the core produces for a player for one Cascade MUST be exactly those events that player observed, and nothing else (10-turn-model.md TM-2150). It MUST NOT contain gaps, placeholders, activation indices, per-event timestamps, or any field whose value varies with unobserved activity (TM-2160).

Consequently the EventSeq and causal reference of AR-290 are referee-side fields: they MUST be stripped at the view-filter boundary and replaced by a per-player ordinal dense in that player's own stream. The relative order of a player's own observations MUST be the Cascade order restricted to those observations, and that disclosure is deliberate and permitted (TM-2190).

Rationale: without compaction the stream leaks by omission. A client receiving a per-activation feed could infer that something happened from a gap where it saw nothing, and by correlating gaps with the initiative bands it does know, build a census of an opponent's army from things it never saw. This is the reason AR-290's fields cannot simply be forwarded, and it is the kind of leak that a straightforward implementation produces by accident and no functional test detects.

AR-300 Validation MUST be server-authoritative in networked games: only orders validated and sequenced by the authoritative core instance enter the log. Client-side pre-validation MUST use the same @everylastcity/core package at the same version — the identical TypeScript source, imported by the Node server and bundled into the browser client, not a reimplementation and not a port — so legal-move UI never diverges from the authority.

This is the decisive property of the stack and it MUST be protected mechanically: the client bundle and the server MUST report the core's package version and its build digest, and a server MUST refuse a session whose client reports a core version outside the compatibility window it advertises (AR-920). Rationale: one implementation of the rules used for both preview and authority is what makes fog enforcement and anti-cheat cheap; the moment a second implementation exists, every divergence between them becomes a player-visible bug the tests cannot see.

AR-310 Order application MUST be atomic: an order either fully applies (all events emitted) or is rejected with no state change.

AR-320 Rejections MUST be typed (NotYourTurn, UnknownUnit, RuleDisabled, TerrainForbidden, NoMovementLeft, HostFull, WouldExceedStack, PhaseForbidden, StaleState, …), machine-readable, and deterministic — the same (state, order) MUST produce the same rejection everywhere, including which rejection when several apply, so validation order is part of the contract and is fixture-tested. Rejections form a discriminated union under AR-245 and are returned, not thrown (AR-140). Rejected orders MUST NOT enter the canonical log (they may be diagnostics-logged).

AR-330 There is no undo of an applied order, and the turn model means none is needed. The core MUST NOT expose a retraction, rollback, or inverse-event mechanism for an order record already accepted into the log, and no such order kind may be added (AR-270). Revision is served entirely by three mechanisms the turn model already owns, and the core MUST provide exactly these and no fourth:

To undo The mechanism Owner
A choice the player has changed their mind about Issue another record for the same target; the later log sequence number wins 10-turn-model.md TM-230, TM-360
An order the player wants gone rather than replaced ClearOrder on the named stack level TM-220
A commitment made too early RevokeCommit, while the Orders phase is still open TM-220

Rationale: this is the clearest thing the turn model deleted from the architecture, and it is worth saying why the deletion is safe. Undo was hard in an interactive engine because submitting an order resolved it: the unit moved, the fog lifted, and taking it back meant inventing inverse events plus an anti-scouting rule to stop players using undo as a free reconnaissance sweep. Under Orders and the Cascade an accepted record resolves nothing (AR-220, TM-350): it writes the issuing player's own state and waits. No unit has moved, no fog has changed (TM-340), and no other player can observe that the record exists at all. "Undo" is therefore just "write a different value", which last-write-wins gives for free — with no inverse events, no retractability flag, no per-player knowledge-delta test, and no possibility of an undo that changes what the rules compute. The affordance a player actually wants — stage, revise, and see the effect before committing — is client-side and never reaches the core (AR-275).

The previous revision of this requirement specified a logged RetractOrder restricted by three conditions (no PRNG draw consumed, no combat caused, no information change for any player). All three conditions are now vacuous: nothing an order record does can consume a draw, cause combat, or move a tile between knowledge levels, because nothing an order record does resolves. The requirement is withdrawn rather than relaxed, and the identifier is retained as the prohibition so that citations of it resolve to a rule rather than to a gap.

AR-340 Idempotent submission: resubmitting an order with a ClientTag already in the log MUST return the original result without re-applying (async-multiplayer retry safety; see 05-multiplayer.md).

5. PRNG policy

AR-350 All randomness MUST come from a seeded, versioned PRNG owned by the core, and no value that reaches state may originate anywhere else. The prohibition on privately constructed generators is scoped exactly as AR-400's closing prohibition scopes it:

Rationale for the scoping: 06-ai.md AI-195 and AR-400(b) both exist so that a plugin preferring its own internal PRNG stays deterministic; a flat ban on plugin-constructed generators would make the validator reject the case those two requirements were written to permit.

The algorithm is PCG32 (PCG-XSH-RR 64/32) as published; the implementation is versioned as rng/1 and recorded in the save manifest. Any future change to algorithm or draw discipline bumps the RNG version and the RulesVersion (§13).

TypeScript representation (normative). PCG32's state and increment are 64-bit and its step is a 64-bit multiply-add, which number cannot represent exactly. The in-repo implementation MUST carry each 64-bit quantity as a pair of unsigned 32-bit Ints ({hi, lo}) and MUST perform the multiply-add with Math.imul and explicit carry propagation; it MUST NOT use bigint on the draw path (allocation cost against AR-770) and MUST NOT use number multiplication (inexact above 2⁵³). A bigint reference implementation MUST exist alongside it, and CI MUST assert that (a) the two agree over a long generated run and (b) both reproduce the published PCG32 reference test vectors. The canonical encoding (AR-620) writes each stream's (state, inc) as two u64 little-endian values.

AR-360 Bounded integers MUST use Lemire's unbiased multiply-shift rejection method (specified in-repo with test vectors). Modulo reduction MUST NOT be used (bias and porting hazards). Lemire's method requires the full 64-bit product of a 32-bit draw and the bound; that product MUST be computed with the same Math.imul hi/lo decomposition as AR-350, never with number multiplication — a 32×32 product reaches 2⁶⁴ and would be silently rounded. The rejection threshold MUST be computed exactly, and the rejection loop's draw consumption is part of the draw discipline of AR-380: a re-implementation that rejects differently produces a different game.

AR-370 Streams divide into rules streams, which are simulation state, and AI streams, which are not.

Rules streams — the 64-bit game Seed is expanded via SplitMix64 (implemented under the same hi/lo discipline as AR-350) into exactly four independent named streams, and a fifth MUST NOT be added without the owning document requiring it:

Stream Drawn by Owner
mapgen Map generation, and the single scalar worldgen seed of AR-400(b) 01-game-rules.md GR-1470
spawn Setup-time selections the core makes after the generator returns — chiefly which generated start position each seat receives. 01-game-rules.md GR-1570 requires the generator to place start cities but does not say who gets which; wherever that choice is made by draw rather than by a setup parameter, it MUST come from here and MUST NOT consume mapgen, so that a change in the generator's draw count cannot move seat assignment this document, pending GR (open question 26)
victory The generation-time schedules of 14-victory.md VC-170, drawn in the order VC-170 states and stored as configuration 14-victory.md VC-170
misc Anything a future subsystem needs that is none of the above, so that adding one does not perturb the three that exist this document

Each rules stream's (state, inc) pair is part of simulation state and is serialized and hashed with it (initial.bin, snapshots, TurnHash, StateHash).

Two subsystems that would obviously have been streams are deliberately not streams, and MUST NOT be given one. Combat draws exactly one value per Clash from the positionally addressed coreHash of AR-135, never from a cursor (11-combat.md CB-800, CB-810). City names are derived from the game seed and the city's sequence number (01-game-rules.md GR-840), which is the same positional construction. Both are therefore functions of what is being resolved rather than of how many resolutions preceded them, which is what lets a preview, a skipped combat, or an AI evaluating a hundred thousand hypotheticals leave the game's outcomes untouched. The economy draws nothing at all (12-economy.md EC-060).

Rationale for shrinking the registry: a stream is a cursor, and a cursor makes every draw a function of the draw count before it. Every stream retired in favour of positional addressing removes a class of divergence — a skipped draw, a preview that leaked, a replay that must reproduce a call sequence — from the subsystem that carried it. The four that remain all draw once, at generation time, before any order exists, which is the one place a cursor costs nothing.

AI streams — an AI plugin's rng.draw(streamKey) (06-ai.md AI-390) draws from the logical stream ai/<playerId>/<streamKey>, which MUST be served by a stateless derivation, not by advancing a stored stream. This requirement owns the derivation normatively; 06-ai.md cites it rather than restating it. The host MUST compute the returned u64 as a pure function of exactly this tuple, in this order:

Component Value
game Seed the 64-bit root seed (never itself exposed to the plugin — 06-ai.md AI-195)
player position the PlayerId of the AI's own position
turn number TurnNumber at the moment of the call
stream key the plugin-supplied streamKey string, hashed ordinal-case-sensitive (AR-100)
RNG version the rng/N version of AR-350
draw counter the number of rng.draw calls this AI instance has already made for this stream key on this turn, starting at 0

The draw counter is what makes successive draws differ: without it every rng.draw("k") in a turn would return the same u64, which is not a stream. It is a per-invocation input to the derivation, not stored stream state — the host keeps it as transient bookkeeping in the plugin session for the duration of the turn, and it MUST NOT exist in the Game aggregate, appear in initial.bin, snapshots, TurnHash, or StateHash, or be included in the canonical encoding of AR-620. Neither may any other AI stream material. The derivation MUST use the same one-way construction AI-195 requires, so that a plugin holding any number of draw results can recover neither the root Seed, nor any rules stream, nor another position's ai/<playerId> values. rng.draw calls therefore mutate no serialized state and cannot desync a game; AR-950(i) continues to require that no ai/ entry participates in any hash and that replay reproduces identical hashes with rng.draw never called.

Rationale: a replay never re-invokes AI code (06-ai.md AI-350) — it derives everything from the order log — so a persistent ai/* stream advanced by out-of-band host calls would be unreproducible by construction, breaking AR-070's bit-identical invariant, AR-130 desync checks, and AR-570/AR-670 container and snapshot verification in every game containing an AI player. Statelessness also keeps the derivation compatible with the private-stream contract of 06-ai.md AI-390 and lets a plugin be swapped, hot-reloaded (06-ai.md AI-250), or substituted mid-game without touching hashed state.

AR-380 Draw discipline: every rules-affecting draw site is enumerated in code with a stable site ID; the number and order of draws for a given (state, order) is part of the deterministic contract and covered by replay tests. Speculative/preview computations (combat-odds preview, pathfinding) MUST NOT draw from any stream.

The core MUST additionally support an opt-in draw trace — an ordered record of (site ID, stream, draw index) for every draw, reduced to a rolling digest — which is off in normal play and enabled by the CLI and by the determinism CI job. Its purpose is localization: when two platforms disagree, comparing draw traces identifies the first divergent draw site instead of merely reporting that two hashes differ. The trace MUST NOT be part of state, MUST NOT be encoded into any container, and enabling it MUST NOT change any hash (property-tested, AR-950).

AR-390 Combat draws are derived, never logged. A Clash's single random value is a pure function of coreHash(seed, turn, initiatorSequenceNumber, attackOrdinal) (AR-135, 11-combat.md CB-800, CB-820, CB-830). It MUST NOT be written to the order log, and a replay MUST re-derive it rather than read it back — the same rule AR-250 states for every other derived result. A ClashResolved event MAY carry the derived value for auditability and for the referee view; the verifier (§11) MUST fail if a re-derived value differs from a cached event's.

Because the value is addressed by the identity of the engagement rather than by a cursor position, a replay that resolves the same engagements reproduces the same values whatever else changed around them, and a preview MUST consume nothing (AR-380, CB-860).

AR-400 Map generation runs in a WASM world-builder plugin (07-modding-content.md). A generator's entropy MUST come from the host and from nowhere else. The worldgen ABI MUST therefore expose exactly two host-owned sources and no others:

(a) the host-owned mapgen stream, via the ABI's draw functions; and

(b) exactly one scalar worldgen seed — a single u64 the host draws from the mapgen stream at request-construction time and passes in the request record, which is what 07-modding-content.md MOD-640's seed: u64 field carries. Where the game has AI positions, that value MUST be one-way derived before it is handed over, per 06-ai.md AI-195, so possessing it reveals nothing about the game Seed or any other stream. A generator MAY use it to seed its own internal deterministic PRNG.

Everything else is forbidden: no clock, no environment, no machine identity, no host-supplied entropy beyond (a) and (b), and no plugin-constructed RNG that is not seeded from (b) (enforced by the sandbox of 07-modding-content.md MOD-590/MOD-600). Because the seed of (b) is itself drawn from the mapgen stream, it is deterministic and replay-safe — it is stream-derived material, not ambient entropy — which is the property this requirement exists to protect. The seed MUST be recorded in Map.Provenance (AR-150) and in setup.bin (AR-567) so a generation can be re-attempted. This is how 01-game-rules.md GR-090's core-owned-PRNG rule becomes enforceable for map generation rather than advisory.

Consequently, for a fixed generator build, identical (dimensions, wrap, player count, seed, options, terrain set) MUST yield an identical map (07-modding-content.md MOD-650), so seed sharing and setup previews are exact. Reproducibility across different builds of a generator is not promised — a rewritten generator draws differently from the same stream. Therefore the materialized map is stored in the initial state (AR-640) and replay never re-runs generation; generator provenance (plugin id, version, options, seed) is recorded so a generation can be re-attempted with the same build.

Rationale: restricting entropy makes same-seed ⇒ same-map a real guarantee within a build, which is what players and the setup preview need; storing the generated map keeps the determinism contract independent of mod authors' version churn, which is the part no ABI restriction can fix.

6. Per-player fog-filtered views

AR-410 The core MUST expose exactly one read surface per player: PlayerView(playerId) — a fog-filtered projection — plus RefereeView (full state) available only to the authoritative host process and replay tools. Clients in fog-enforced games MUST never receive RefereeView data or unfiltered events (anti-cheat; see 05-multiplayer.md). The one sanctioned unfogged player projection — the disclosed AI Full Vision cheat of AR-490 — is a PlayerView constructed with fog disabled for that position, not RefereeView: it therefore still omits referee-only data such as the combat rolls stripped by AR-460.

AR-420 View filtering MUST be structural, not advisory: the serialized PlayerView and the player's event stream are constructed exclusively from data the player is entitled to. It MUST be impossible to reconstruct hidden state from anything transmitted (no "hidden" flags on included objects, no padding-derivable counts, no timing side channels from skipped hidden work in transmitted payload sizes).

AR-430 Per-tile knowledge has exactly three levels, and two of them are stored. Unexplored, Explored and Visible, whose semantics 01-game-rules.md owns (§9). Architecturally:

(a) The Explored content is the Terrain Record of GR-1140 — terrain, Installations, River edges, city presence, and the observation turn — and MUST be read from that record, never reconstructed from the live world at read time (AR-155). Terrain within it is permanent (GR-1150); everything else in it is as-of the observation turn and MUST NOT be refreshed while the tile is unobserved (GR-1160).

(b) Visible is derived from current observation and is the one level that MAY be recomputed, because it is a function of the present world alone. AR-480 owns its storage.

(c) The setup toggles GR-1630 defines — fogEnabled = false, intelligenceDecay = false — MUST be SetupConfig parameters (AR-040), MUST be inside the hashed initial state, and MUST NOT be reachable as a runtime mode. fogEnabled = false MUST be implemented as every tile at Visible for every player through the same view surface as any other game, never as a second, unfiltered read path (AR-410).

AR-440 Detection is data, and the core MUST NOT know what a submarine is. Whether player P observes unit U MUST be computed from the definition fields and capability flags 02-units-and-industry.md owns — vision and visionAir (US-120), and the detection flags submerged, sonar, highAltitude and intercept (US §3.4) — together with the observation procedure 01-game-rules.md §8 owns. No detection rule may be written as a table of unit identities, and no code in the core may branch on a unit's id or name to reach one (US-060, AR-190).

Two obligations follow from the rules as written and bind the implementation. Concealment MUST be recomputed at the start of each activation rather than cached across a Cascade (US-640), which makes detection part of the activation hot path and therefore part of AR-760's simulation-only budget. And a concealed unit that attacks MUST lose concealment for the remainder of the turn (US-650), so the view filter MUST be able to raise what a player is entitled to see mid-Cascade, not only lower it.

Rationale: the previous revision of this requirement was a hard-coded matrix of which unit types could see which. Data-driving it is not a stylistic preference — it is the difference between a custom unit set being a real feature and being a promise. One identity check in the detection path and a modder's new hull can never be a submarine, and no test would notice.

AR-450 Identity disclosure is graded, not binary. What a player learns about an observed foreign entity MUST be exactly what its Contact admits at its fidelity grade (01-game-rules.md GR-1210, GR-1260), and the view filter MUST project it from that single authoritative Contact record. It MUST NOT project from the true entity and then remove fields, and it MUST NOT hold a second, richer record that a defect could serialize.

Because re-observation sets the grade and may lower it (GR-1240), a PlayerView MUST be able to represent a Contact whose fidelity has fallen, including one whose position has degraded from a tile to a region (GR-1330 via GR-1260) — the projection is not monotone and an implementation that assumes it is will silently over-disclose.

AR-460 Referee-only material, and the game seed above all. A PlayerView and a player-bound event stream MUST NOT contain, in any form: the game Seed; any rules stream's (state, inc) pair (AR-370); a coreHash input tuple for an engagement the player did not observe; or the derived value of any Clash the player did not observe (AR-135, AR-390). RefereeView, replay tooling and the authoritative host retain all of it.

The seed clause is the load-bearing one and it is a new obligation created by AR-135. Because a Clash's outcome is a pure function of (seed, turn, initiatorSequenceNumber, attackOrdinal) (11-combat.md CB-800), a client holding the seed can compute the result of every attack it might make before making it, and every attack an opponent might make against it. That is a total defeat of the combat model's uncertainty, obtained with no exploit and no modified client. Consequently: the seed MUST live in initial.bin and never in a view payload; a fog-enforced client MUST hold only its view cache and never initial.bin (AR-590); and where a participant legitimately holds the full container — solo, hot seat, relay-only, turn-bundle (AR-540) — the seed is licensed to them exactly as the rest of the container is, and this requirement does not pretend otherwise.

Rationale: the positionally addressed hash is the largest determinism win in the specification and it moves one property from the engine to the transport. A stream cursor is useless to an attacker without the exact draw count; a positional hash is useful to an attacker with nothing but the key. Naming that here is cheaper than discovering it in a tournament.

AR-470 An effect may be observable when its cause is not. Bombardment resolves against a tile (02-units-and-industry.md US-590, US-600) and the target need not be observed by the firing player. A player-bound event stream MUST therefore carry only what that player observed — that fire was resolved, and any destruction they could see — and MUST NOT disclose the existence, type, strength, or survival of an entity they did not observe, whether directly, by an omitted field, or by an inferable count (AR-420). 01-game-rules.md GR-1180 states the same prohibition as a rules obligation and makes it testable; AR-950(c) is the test.

AR-480 Each player's view maintains fog state as two bits/tile (knowledge level) — packed four tiles per byte in a Uint8Array, chunked to match AR-710 — plus a sparse last-seen table. Incremental update: any event with a visibility set touching player P updates P's view; full recomputation MUST be possible and MUST equal the incremental result (property-tested, §14).

AR-490 PlayerView MUST include everything a client or an AI needs to act legally and to see the consequences of a choice before committing to it, without consulting documentation: a legal-order query API, per-tile movement cost and passability for a named unit, the exact pre-commitment combat readout (11-combat.md §16 — pure, consuming no draw, AR-380, CB-860), the economy's forecasts of every committed quantity and every brake (12-economy.md EC-030's forecastability clause), the player's own order stacks, Stances and Sanction values, and the Initiative each of their own units will carry into the coming Cascade (10-turn-model.md TM-370). AI plugins receive PlayerView only (06-ai.md); AI difficulty MUST come from the quality of play, never from information a human seat could not have (00-overview.md OV-110).

The single exception is the disclosed AI Full Vision cheat option (06-ai.md AI-330): when that option is recorded in the game configuration for a position, the core MUST be able to construct that position's PlayerView with fog disabled — every tile at knowledge level Visible, all cities and units included — delivered through the same PlayerView surface and the same view/event plumbing as any other position (AR-410, AR-420). Constraints: the option MUST be part of hashed setup data so it appears in saves, replays, and the rules review (AI-100); it MUST be off in every shipped preset; a position without the recorded option MUST have no code path that can produce a fog-disabled view; and the option MUST NOT alter what the rules compute for that position — it widens what is shown, never what is legal (00-overview.md OV-110). Human clients are never eligible: full vision is expressible only for a position whose controller kind is an AI plugin.

AR-495 The observation stream is the unit of delivery for a Cascade, and its timing is normative. The core MUST produce, per player per Cascade, the compacted stream of AR-295, and the delivery machinery MUST honour the transmission rule 10-turn-model.md owns:

(a) In a game with two or more human seats, a player's whole stream MUST be handed to the transport as a single payload after the Cascade has completed, never incrementally (TM-2170). The view layer MUST therefore be able to accumulate a whole Cascade's worth of per-player observations and emit them once. A per-activation push is not a permitted optimisation, however cheap: incremental delivery reintroduces the timing channel that compaction was written to close, because a client that receives nothing for a while learns that something it cannot see is taking a long time.

(b) In a solo or hot-seat game the stream MAY be delivered incrementally (TM-2180), because there is no opponent to whom timing could leak. This is the only place in the view path where the number of human seats changes behaviour, and it MUST be a delivery decision made by the host, never a rules branch inside the core (AR-040).

(c) AI seats MUST NOT read the stream at all (TM-2180); they read state through PlayerView like any other seat (AR-490).

(d) A player MUST be able to re-read the previous Cascade's stream during the following Orders phase without any effect on the simulation (TM-2230), so the stream MUST be retained for at least one turn and MUST be replayable from the container by re-derivation (AR-250).

Rationale: this is the requirement that stops a straightforward, entirely reasonable implementation from leaking. Streaming events to clients as they are produced is the obvious design, it is what an interactive engine does, and under the Cascade it is a covert channel: activation order correlates with Initiative, Initiative correlates with unit class, and a client timing its own silences can assemble a census of an army it never saw. Buffering the whole Cascade costs a few hundred kilobytes and closes it completely.

AR-500 View serialization MUST be versioned and diff-friendly: the server sends ViewDelta messages carrying the observation stream and its state consequences, with periodic ViewCheckpoint full states; a client that misses deltas requests a checkpoint (reconnection, 05-multiplayer.md). Delta emission is subject to the timing rule of AR-495 — a ViewDelta for a Cascade is one payload in a multi-human game, whatever its internal structure.

ViewDelta and ViewCheckpoint MUST use the core's own canonical, schema-versioned encoding (AR-620, AR-920) and travel as opaque binary payloads over whatever message channel the transport provides. A transport MUST NOT substitute a framework's automatic state-synchronization facility for this encoding. Concretely: Colyseus's schema state-sync computes and pushes binary deltas of mutated server state, and adopting it as the carrier of game state would make the pushed snapshot — rather than the order log — the thing clients depend on, destroying the derivability that replay, spectating, desync detection (AR-530) and mode switching all rest on. Orders travel up; events and view deltas travel down; ephemeral non-authoritative presence data (who is connected, lobby chat) is not game state and is outside this requirement. 05-multiplayer.md owns the transport and the room lifecycle; this requirement owns only what the payload is.

7. Spectating, replay, and desync detection (derived features)

AR-510 A replay is the save container itself (§8): initial state + order log replayed through the same core version. The replay API MUST support: seek-to-turn (via nearest snapshot + fast-forward), step-by-order, step-by-event, and per-player perspective (render any PlayerView or the referee view, subject to AR-460-style visibility for live games).

AR-520 Spectating a live game = subscribing to a view stream: either a chosen player's filtered stream (delayed if the lobby says so) or the referee stream for finished games, and for live games only where 05-multiplayer.md's spectating policy permits it (MP-1060 gates live omniscient view on unanimous consent of active humans; MP-1070 makes live omniscient spectating unavailable in relay-only and bundle games, because the server holds no filtered state to serve it from — and casual games default to relay-only, MP-350). Spectator access control is 05-multiplayer.md's concern; the core provides the filtered streams and MUST NOT assume a game class is eligible for the referee stream merely because it is casual.

AR-530 Desync detection: every TurnEnded event carries TurnHash (AR-130). Any consumer maintaining a mirrored state (client prediction, secondary server) MUST compare hashes each turn; on mismatch it MUST discard local state, resync from an authoritative checkpoint, and emit a desync report containing both hashes, OrderSeq range, and platform info. Desync reports are collected by CI and telemetry (08-services-platform.md).

AR-540 Bug-report export: a client MUST be able to export the sealed container it already legitimately holds as a repro bundle, which replays deterministically by AR-070 and therefore reproduces the defect on the maintainer's machine rather than describing it.

Export is scoped by what the exporter is entitled to hold, never by request: solo, hot-seat, relay-only, and turn-bundle participants hold the full container by construction (AR-590) and MAY export it at any time. In a fog-enforced game the client holds only its view cache (AR-590), and the export it can offer is that view cache; the full container becomes available to a participant only under 05-multiplayer.md MP-730's conditions, and the finished-game repro artifact is MP-1100's replay. @everylastcity/core and @everylastcity/server MUST NOT expose any endpoint that serves a full container to a client that does not already hold one — a "bug report" is not an authority to widen a player's information (AR-410, AR-420). Operator-side capture of a live fog-enforced game is 05-multiplayer.md's and 08-services-platform.md's concern, not a client capability.

8. Save/replay container format

AR-550 One container format MUST serve saves, replays, turn bundles, and bug-report bundles. This document owns the extension registry for that format; every role below is the same ZIP container of AR-560 with a role profile, never a second format:

Extension Role Owner of role semantics
.elc Saves, replays, bug-report/repro bundles, branches and bookmarked restores this document (§7, §8)
.elcturn Turn bundle for turn-bundle (file-play) mode, including the pre-launch sign-in bundle 05-multiplayer.md MP-750/MP-760/MP-800 (profile: AR-565, AR-567)

(.elcmod is a mod package, a different format entirely — 07-modding-content.md MOD-020.) A reader MUST identify a container by its manifest.json, not its extension, and MUST open a correctly formed container whatever it is named; the extensions exist for OS file association and for telling a player at a glance what they were sent.

Identity is GameId (UUIDv7, AR-150), never a file path or a display name. A game's display name is metadata and MAY be changed at any time without affecting identity; any number of save files, exports and branches (AR-600) may exist for one GameId, and two containers carrying the same GameId with divergent order logs MUST be detectable as such by their ChainHash heads rather than by their names.

Rationale: a single-slot, name-keyed save is the shape a game acquires when saving is a file dialog rather than a format decision, and it costs the player everything interesting — no branches, no bookmarks, no "keep the turn before I lost the fleet". Making identity a UUID and the name a label costs one field and buys AR-600's whole branching model.

AR-560 The container is a ZIP archive with documented entries — inspectable with standard tools (pillar 4, radically open).

Compression is portability-constrained. Every reader MUST support deflate (RFC 1951), and every container that leaves one machine — turn bundles, workshop uploads, bug-report bundles, exported replays — MUST use deflate for all entries. zstd MAY be used only for server-local and client-local storage, never for interchange, and a writer using it MUST be able to rewrite the container as deflate on export. Rationale: in the .NET plan every runtime had zstd in the box. In this stack deflate is available everywhere the client runs (DecompressionStream, node:zlib, and a pure-TypeScript fallback), while zstd would mean shipping a WASM codec into the browser bundle and making a container unreadable by a client that lacks it. Interchange portability outranks a few percent of file size. Compression MUST NOT affect any hash: all digests in AR-130 and AR-570 are computed over uncompressed entry bytes.

Documented entries:

Entry Content
manifest.json Format version (major.minor), engine version, RulesVersion, commandLogicVersion (13-command.md CM-2050), advisoryVersion (CM-2080), RNG version, GameId, lifecycle phase marker (SignIn | Active | Finished, AR-567), display name, creation/modification timestamps (metadata), content refs {unitSetKey, version, SHA-256}, player roster + controller kinds (plus the per-seat fields of AR-565 in the turn-bundle role), per-entry SHA-256 table, StateHash of latest snapshot, ChainHash head, audit.log chain head, optional ed25519 signature. No mode or preset name appears here: rules behaviour is determined by SetupConfig inside initial.bin (AR-040), and a manifest field naming a mode would be a second, unhashed answer to the same question
setup.bin (pre-launch only) The full SetupConfig and VictoryConfig of AR-150, roster with claimed seats and public keys, generator provenance and seed, PluginRuntimeConfig (AR-857) — present while phase == SignIn, before a map exists (AR-567)
initial.bin Canonical initial state: materialized map, starting cities and units with their complete order stacks (AR-205), the full SetupConfig and VictoryConfig (AR-040, AR-150) and PluginRuntimeConfig where present (AR-857), the seed, and the initial rules-stream states (AR-370). Absent while phase == SignIn
orders.log Append-only framed order records (AR-260)
audit.log Hash-chained non-order control records — rollbacks, mode switches, admin actions (AR-563). Excluded from simulation-state derivation and replay
events.log (optional) Cached derived events for fast open/spectate; MUST verify against re-derivation (AR-390)
snapshots/NNNNNNNN.bin Canonical state snapshots keyed by OrderSeq (§9)
ai/<playerId>.<OrderSeq>.bin (optional) Opaque AI memory blobs (06-ai.md AI-460). Outside hashed simulation state: excluded from TurnHash, StateHash, and snapshot verification (AR-570(c), AR-670); covered by the ordinary per-entry SHA-256 table
handoff/NNNN.sig (turn-bundle role) Per-hand-off signature-chain records (AR-565, 05-multiplayer.md MP-770)
meta/ Chat transcript, per-player map labels/waypoints (AR-170), bookmarks, client color overrides

AR-563 Audit/control log. Non-order records that describe actions on a game rather than in it MUST live in audit.log, a separate append-only, hash-chained entry (same framing and ChainHash construction as AR-260, its own chain, head recorded in the manifest). It MUST carry at minimum: RollbackExecuted {target boundary, actor, timestamp, reason, consent record, pre-rollback ChainHash head, retained-history reference} (05-multiplayer.md MP-740), ModeSwitch {from, to, actor, timestamp} (MP-900), SeatChanged {substitution/replacement, actor} (MP-1010/MP-1020), and BranchCreated {parent GameId, branch OrderSeq} (AR-600). Constraints:

(a) audit.log MUST be excluded from simulation-state derivation. LoadFrom, replay, snapshot verification, TurnHash, and StateHash MUST ignore it entirely; a container that is byte-identical except for audit.log MUST replay to the same hashes. It is covered by the per-entry SHA-256 table like any other entry.

(b) It MUST NOT be used for anything a player did in the game. Authority-issued orders (auto-turn, AI substitution, plugin fallback) are ordinary order records carrying Issuer = Authority(reason) per AR-260 and MUST appear in orders.log, because replay must apply them; the audit log MAY additionally record that a policy fired.

(c) Records MUST be immutable and MUST survive rollback truncation, branching, migration (AR-910), and export — 05-multiplayer.md MP-740 requires an executed rollback to remain permanently visible in the game's history, which is only possible if the audit chain is never truncated with the order log.

Rationale: 05 requires persistent records of rollback, mode switch, and auto-turn, but the order log is exactly the thing whose bytes determine the game; mixing administrative records into it would either perturb replay or force replay to skip records it cannot distinguish. Separating the channels lets the audit trail grow monotonically while orders.log stays a pure function of play.

AR-565 Turn-bundle profile. A container in the turn-bundle role (.elcturn, 05-multiplayer.md MP-750/MP-760) MUST carry, beyond AR-560's base entries:

Field/entry Content 05 semantics
manifest.roster[].publicKey Per-seat ed25519 public key, registered at seat claim MP-770
manifest.bundleSeq Monotonic bundle sequence number, incremented on every hand-off export MP-750, MP-860 (stale-bundle detection)
manifest.activeSeat The seat entitled to act on import MP-750, MP-830
manifest.protocolVersion Wire/view schema version (AR-920) so an import can be refused cleanly MP-750
handoff/NNNN.sig One record per hand-off: {bundleSeq, senderSeatId, signedAt, signature} over (canonical manifest bytes ‖ orders.log ChainHash head), forming a chain — record N MUST also cover record N−1's digest MP-770

Verification on import MUST fail — distinctly, per AR-570 — if any signature is invalid, if a hand-off record is missing from the chain, if bundleSeq does not advance monotonically along that chain, or if a seat signed a hand-off it did not hold. A bundle MUST be self-contained (full initial.bin + full orders.log + audit.log, MP-750), never a delta (AR-630). 05-multiplayer.md owns trust, possession, and rollback-by-agreement semantics; this requirement owns only the encoding they need.

AR-567 Pre-launch (sign-in) profile. The container MUST be able to represent a game that has not yet been launched, because turn-bundle sign-in circulates the game before map generation: the map is generated only when the last seat is claimed, on the completing machine (05-multiplayer.md MP-800). Such a container MUST set manifest.phase = SignIn, MUST omit initial.bin, orders.log, events.log, and snapshots/, and MUST carry setup.bin (AR-560) with the full setup, the roster with claimed/unclaimed seats and their public keys, and the generator provenance and Seed fixed at creation. Hand-off signatures (AR-565) apply during sign-in exactly as during play, over (manifest ‖ setup.bin digest) while no order log exists.

When the last seat is claimed, the completing client MUST materialize initial.bin from setup.bin per AR-400/AR-640 using the recorded seed and provenance, flip manifest.phase to Active, retain setup.bin for audit, and sign the resulting bundle. Readers MUST reject a SignIn container that carries initial.bin, and an Active container that lacks it.

Generator-build mismatch at completion is a refusal, not a warning (decision). AR-400 promises identical (dimensions, wrap, player count, seed, options, terrain set) ⇒ identical map only for a fixed generator build, so a completing client running a different build of the recorded generator would materialize a different map than any other seat would have. The completing client MUST therefore verify that the world-builder plugin identity and version recorded in setup.bin match the build it holds, and MUST refuse to complete — with a distinct, named error naming both versions — rather than generate. It MUST NOT substitute a different build, and MUST NOT silently upgrade.

Rationale for deciding rather than deferring: every other seat signed the setup, and the map is the one part of the game they agreed to without being able to see it. A completing client that quietly used a newer generator would hand them a different game than the one they signed, and the signature chain of AR-565 would attest to it. Refusing costs one player a plugin install and an obvious error message; the alternative costs a game nobody can audit. The residual question is operational rather than architectural — how a player obtains the exact recorded build — and it belongs to 07-modding-content.md's distribution model.

AR-570 Integrity: readers MUST verify (a) per-entry SHA-256 against the manifest, (b) the order-log ChainHash, (c) on full open, that replay from initial.bin (or nearest snapshot) reproduces the manifest StateHash, (d) the audit.log chain against its manifest head (AR-563), and (e) in the turn-bundle role, the hand-off signature chain (AR-565). Check (c) applies only to containers whose manifest.phase is Active or Finished; a SignIn container (AR-567) has no state to reproduce and is verified by (a), (d), (e) and its setup.bin digest alone. Entries excluded from hashed simulation state — audit.log, ai/, meta/, handoff/ — MUST NOT participate in check (c). Failures MUST be surfaced distinctly (corrupt vs tampered vs version-mismatch), never silently repaired.

AR-580 Encryption is not a container feature, and a password in a file is not a trust boundary. The format MUST NOT define a password, a passphrase, or an encrypted-payload entry. Trust in a networked game is server-side authentication plus fog enforcement (05-multiplayer.md, 08-services-platform.md); trust in a passed file is the ed25519 signature chain of AR-565, which proves who produced this bundle rather than pretending to withhold its contents. Containers at rest MAY be wrapped by platform or user encryption outside this specification.

Rationale: a container's holder can read it, whatever the format claims. Encrypting a save whose key must travel with it to be usable buys nothing but the belief that it bought something — and that belief is what leads to shipping hidden information inside a file the opponent holds. AR-590's rule is the honest one: a fog-enforced client is never given the container in the first place.

AR-590 Fog-safe distribution: in fog-enforced multiplayer, full containers exist only on the authoritative host. Clients persist a view cache (view checkpoints + deltas), never initial.bin/orders.log of other players' hidden information. Hot-seat and solo games hold the full container locally (all information is locally licensed anyway).

AR-600 Bookmarks and branching: a bookmark is a named pointer {name, OrderSeq} in meta/. "Restore" MUST create a new container branched at that point (new GameId, ParentGameId + branch point recorded) and MUST NOT overwrite the container it was taken from — restoring is non-destructive at the format level, and no client setting may make it destructive. How many restore points a client offers, and how it presents them, is 04-ui-ux.md's decision; the format's answer is always "as many as you kept".

AR-610 Size budgets: a reference game (AR-760) at 500 turns MUST serialize to ≤ 32 MB including default snapshots; a typical 8-player 200×200 game ≤ 4 MB. Order records average ≤ 32 bytes pre-compression.

AR-620 Canonical binary encoding (used for initial.bin, snapshots, hashing): little-endian, schema-versioned, fixed field order, varint lengths, no padding, no floating point. Encoders and decoders MUST be explicit per-type functions — hand-written or generated from the schema into checked-in TypeScript — operating on DataView/typed arrays. Reflection-driven, decorator-driven, and JSON.stringify-based serialization MUST NOT be used: JSON output depends on property insertion order, and a reflective encoder makes the byte layout a function of declaration order in the source, which is precisely the kind of incidental coupling AR-090 exists to remove. Every encoder MUST have a golden byte-vector test (AR-935).

The encoder MUST reject rather than encode any value that violates the numeric contract: NaN, ±Infinity, −0, a non-safe-integer number, a Fixed raw outside int32, or an out-of-range field. Strings are length-prefixed UTF-8; the encoder MUST reject unpaired surrogates rather than encoding them, because TextEncoder silently substitutes U+FFFD and the round-trip would not be lossless — a hazard specific to JavaScript's UTF-16 strings that did not exist in the withdrawn stack.

The manifest.json entry is JSON and is covered by the per-entry digest of AR-570, so it MUST be written in a canonical JSON form: keys sorted by ordinal UTF-16 code-unit order at every level, no insignificant whitespace, integers only (u64 fields as decimal strings, AR-260), and UTF-8 output. A reader MUST NOT assume the manifest it re-serializes is byte-identical to the one it read unless it re-serializes canonically.

A lossless canonical JSON projection of state MUST be exposed by the CLI (export, AR-900) for tooling and mod authors, under the same canonical-JSON rules; the binary form is authoritative for hashes.

AR-630 Correspondence (async) turn transfer has exactly two mechanisms, and neither is a container delta file:

(a) Server-connected async (cloud async, 05-multiplayer.md §9) transfers turns over the wire protocol of 05-multiplayer.md §5 — the active player's order records are submitted on the connection and the server sequences (AR-260 Seq) and durably stores them. There are no user-visible transfer files at all (MP-610). Who validates, and how a client catches up, depend on the game's authority mode (MP-340), which async games choose per MP-380:

Neither branch uses a delta container.

(b) File play (turn-bundle mode, 05-multiplayer.md §10) transfers the full container.elcturn, AR-565 — on every hand-off, including through the optional relay drop-box (MP-880) and the cloud↔bundle switch (MP-920). Bundles are self-contained by MP-750; a partial "orders since Seq" file format does not exist and MUST NOT be introduced without 05-multiplayer.md §10 adopting and specifying it (obligation on 05-multiplayer.md and on 00-overview.md's glossary, which still carries an "async-turn transfer delta" entry this requirement deletes — open question 10).

Rollback — undoing an agreed number of completed turns — is likewise not file surgery and not a new-identity branch. In server modes it is the audited in-place truncation of 05-multiplayer.md MP-740: the game keeps its GameId (MP-100 requires it stable), the log is truncated to the agreed end-of-turn boundary, a RollbackExecuted record is appended to audit.log (AR-563), and all clients resync. The server MUST retain the discarded suffix as a full history record — stored internally as an AR-600 branch container (new GameId, ParentGameId + branch point) referenced from the RollbackExecuted record — so nothing is destroyed and the rollback stays visible forever, while the live game's identity never changes. In turn-bundle mode the equivalent is a participant re-exporting from an earlier boundary of their retained history under consent (MP-860), with bundleSeq making the rewind detectable (AR-565).

Rationale (decision): AR-600 branching creates a new GameId by design, which is right for "explore from a bookmark" and wrong for "un-do the last two turns of this game". Separating the two — live game truncates in place, discarded history becomes a branch — satisfies MP-740 and MP-100 simultaneously without inventing a second rollback mechanism.

AR-640 Scenario/map files (editor output, 07-modding-content.md) reuse initial.bin's schema minus runtime fields, so CreateGame from a scenario is a projection, not a translation. A scenario that places a unit whose type key is absent from the selected unit set MUST be rejected at load with a named error, not silently loaded with the unit dropped (AR-055 "fail loudly"; 02-units-and-industry.md US-710 requires the same all-or-nothing treatment of a unit set). Rationale: silently dropping a placed unit produces a scenario that loads, plays, and is subtly not the scenario its author tested — the worst of the three available outcomes. Refusing names the missing key and the set that lacks it, which is a five-second fix.

9. Snapshotting

AR-650 Snapshots are an optimization and MUST never be the source of truth (design brief). A snapshot is the canonical encoding (AR-620) of the full state at an OrderSeq boundary, zstd-compressed, with its SHA-256 in the manifest.

AR-660 Default snapshot policy: every 50 turns, at game-phase transitions, and on explicit request; a writer MUST retain at least the latest snapshot and the initial state. Policy is configurable per deployment; snapshot presence never changes replay results.

AR-670 Snapshot verification: writing a snapshot MUST be preceded by (or paired with, in background) a check that replaying from the previous snapshot reproduces it. The CLI verify command (AR-900) re-validates all snapshots in a container.

AR-680 Load path: loadFrom MUST prefer newest verified snapshot + order-log fast-forward, falling back to full replay from initial.bin. Loading the reference game (AR-760) from snapshot MUST take ≤ 2 s on REF-HW warm, and ≤ 4 s cold — the cold figure is stated separately because load is the one budgeted path that runs before V8 has tiered the decoder up, so a single warm number would be a fiction for exactly the case players experience (AR-705).

10. Performance strategy: unlimited maps, multi-core, memory layout

AR-690 Map size: the rules own the range, this document owns the tiers. 01-game-rules.md GR-120 fixes width and height each in [16, 2048], and the core MUST NOT impose a smaller cap of its own, nor accept a larger one. The canonical encoding (AR-620) MUST size its tile-index fields for the full range with headroom (u32 tile indexes, u16 axes) so that widening the rules' bound later is a rules change rather than a format break.

Practical tiers, which exist to give the budgets below a fixture rather than to constrain a player: Standard 200×200 (40,000 tiles), Large 1,000×1,000 (1,000,000 tiles), Extreme 2,048×2,048 (4,194,304 tiles — the rules maximum). Every budget in this section names its tier.

Rationale for tracking GR-120 rather than promising more: the previous revision claimed a 32,768 × 32,768 format maximum, which is a billion tiles — four orders of magnitude past anything the fog, supply and pathfinding budgets here are written against, and past what any of the rules documents assume. An engine limit far above the rules limit is not generosity, it is an untested configuration that a mod will eventually reach.

AR-700 REF-HW (reference hardware for all budgets): 4 physical cores at ~3 GHz x64, 16 GB RAM, NVMe, running Node 22 LTS for headless and server budgets and current-channel Chromium for browser budgets. Budgets MUST also be met at the time on a 2019-class mobile ARM device, and at in a browser tab on REF-HW (main thread idle, sim in a worker, AR-780). The withdrawn plan's separate "4× in single-threaded WASM" multiplier no longer applies: the core is not compiled to WASM (AR-850). Where workers are unavailable, the multiplier is whatever the loss of AR-730's parallel stages costs, and the budgets are stated per-stage so that cost is visible rather than hidden in a single number.

AR-705 Budgets are warm-path budgets, and MUST be reported as such. V8 executes new code in an interpreter and tiers it up under profiling, so the first executions of any path can be an order of magnitude slower than steady state. Every budget in this section is therefore defined as the median over a measured steady-state window after a stated warm-up, and every benchmark MUST additionally report cold-start (first-iteration) figures and the iteration count at which the measurement stabilized. A benchmark that reports only a warm median is non-compliant.

This is a genuine regression against the withdrawn NativeAOT plan, where compiled code was at full speed on its first execution and a single number was honest. Two consequences are normative: budgets that matter to a player's first action — container load (AR-680), the first Cascade and Reckoning — MUST carry an explicit cold figure; and CI performance gates (AR-980) MUST gate on the warm median and alert on cold-start regressions, because a change that pushes a hot function past an inlining or deoptimization threshold is invisible in the warm number until it is not.

AR-710 Tile storage MUST be structure-of-arrays in typed arrays, chunked in 64×64 tile blocks: TerrainId in a Uint8Array, flags in a Uint8Array, ResourceCount in a Uint16Array → ≤ 8 bytes/tile core arrays; sparse side tables (cities, features, mines) keyed by tile index. Unit records live in parallel typed arrays indexed by dense UnitId with a free list and generation counters; a per-chunk unit index provides spatial queries.

Structure-of-arrays is not merely a cache-locality choice under this stack, it is a garbage-collection choice: an array of ten thousand unit objects is ten thousand heap allocations that V8 must trace, and per-tile objects on a Large map would be a million. The core MUST NOT allocate a per-tile or per-unit object on any hot path — iteration exposes index-based accessors or a single reused cursor object, never a freshly constructed record per element — and hidden-class stability MUST be preserved: object shapes in the core are fixed at construction, properties are never added or deleted after construction, and optional fields are represented by a sentinel rather than by undefined-vs-absent.

AR-720 Memory budgets: full authoritative state for the reference game (AR-760) ≤ 128 MB; Extreme-tier maps ≤ 1.5 GB on Node. Per-player fog adds ≤ 1 MB per player on Large maps (2 bits/tile + sparse last-seen). Because bulk state lives in typed arrays (AR-710), most of it is ArrayBuffer backing store rather than traced JS heap, which keeps GC pause times independent of map size — the property that makes AR-780 achievable.

Browser tabs do not get the Extreme-tier budget. A 1.5 GB working set is not a reasonable ask of a browser tab on a consumer machine, and on mobile Safari it is not available at all. Extreme-tier maps are therefore a Node/desktop capability; the browser and Tauri-mobile clients MUST advertise a supported map-tier ceiling, and the setup UI MUST NOT offer a tier the running client cannot hold (04-ui-ux.md owns the presentation). This replaces the withdrawn plan's 32-bit ≤ 4 GB WASM address-space cap with an honest per-platform ceiling; see open question 6.

AR-730 Threading model: all state mutation happens on a single sim context per game — one worker, never shared — which keeps resolution simple and deterministic; parallelism is applied to pure stages across web workers (browser) or worker_threads (Node): per-player view/fog recomputation, view-delta serialization, hashing and compression, and pathfinding candidate evaluation with deterministic tie-breaking, all under AR-120's schedule-independence rule. AI plugin execution also runs on a worker, but it is not a core stage: the client or the server hosts it (AR-855) and hands the core the resulting orders, so it is listed here only because it competes for the same worker pool. Transfers between workers MUST move typed arrays by transfer or SharedArrayBuffer rather than by structured-cloning object graphs. Servers parallelize across games freely — one Node process holds many games and MAY shard them across workers, since games share no state.

AR-740 Fog updates MUST be incremental: a unit move recomputes visibility only in affected radii (dirty-region tracking per chunk), so the cost is a function of the sight footprint, not of map size or player count. Budgets on the AR-760 reference game, REF-HW, warm:

Work Budget
Incremental visibility update for a typical move, summed over every affected player 40 µs on Large maps
From-scratch recompute of one player's whole fog state ≤ 50 ms on Large maps

The 40 µs figure is a component of AR-760's simulation-only per-order budget, not an addition to it: maintaining per-player visibility is what lets AR-290 attach a visibility set to every event, so it happens in the headless verification configuration of AR-770 exactly as it happens interactively. What does not happen headlessly is the work built on top of it — PlayerView projection, knowledge-bitset packing for delivery, and ViewDelta serialization (AR-500) — which AR-760 budgets as a separate row and AR-730 puts on parallel workers.

The previous figure was ≤ 2 ms per move, which was arithmetically impossible against AR-760's ≤ 1 ms per movement order and 20× looser than AR-770's per-order throughput. 40 µs is derived from the budget it has to fit inside rather than asserted independently; see AR-770.

AR-750 Pathfinding MUST use integer movement costs read from data — the per-terrain, per-terrainClass cost table 01-game-rules.md owns (GR-290) indexed by the unit's terrainClass (02-units-and-industry.md US-120), plus whatever Road and other Installation overrides that table defines — never a cost hard-coded in the core and never a cost keyed on a unit's identity (US-060). It MUST use hierarchical abstraction on chunk boundaries for long routes and deterministic tie-breaking, lowest tile index wins, which 10-turn-model.md TM-1480 relies on. A 1,000-tile route on a Large map MUST plan in ≤ 5 ms on REF-HW.

AR-760 Reference game (the normative performance fixture). Large map (1,000×1,000, torus topology per 01-game-rules.md GR-190), 8 players — the maximum GR-030 permits — 2,000 cities, 10,000 live units, the shipped standard unit set (02-units-and-industry.md US-1340) with cities across all four Industry tiers (US-810), and every optional rule module in SetupConfig enabled so that no budget is measured with a subsystem switched off. Budgets on REF-HW, warm per AR-705.

Rationale for maximising rather than typifying: a fixture that models a median game measures the case nobody complains about. This one is deliberately the worst configuration the rules permit, so a budget met here is met everywhere, and a regression shows up in the fixture before it shows up in a player's late game.

Two configurations are budgeted separately, and every budget below names which it belongs to. The distinction is load-bearing because AR-770 aggregates one of them and AR-980 gates both:

Metric Configuration Budget Change from the withdrawn plan
Apply a typical movement order simulation only ≤ 100 µs mean (p99 ≤ 500 µs) restated as a number consistent with AR-770. The old row read ≤ 1 ms without saying which configuration it measured, which put it 10× outside the throughput budget AR-770 states for the same fixture
Apply a typical movement order full interactive ≤ 1 ms (p99 ≤ 5 ms) unchanged in value; now explicitly the delivery-inclusive figure. It decomposes as ≤ 100 µs of simulation plus ≤ 900 µs of projection and serialization across up to 8 players (≈ 112 µs per player)
Full per-player Reckoning (the TM-1930 step list: combat aftermath, the Ledger phase, clocks, command bookkeeping, the Seal) simulation only ≤ 60 ms new row. Without it AR-770's whole-run figure has no derivation
The same Reckoning, plus per-player view projection and delta serialization full interactive ≤ 100 ms unchanged
TurnHash over full reference state both ≤ 25 ms (was ≤ 10 ms) relaxed, honestly. The digest is now pure TypeScript synthesizing 64-bit lanes from 32-bit ones (AR-130) instead of a native, SIMD-accelerated primitive. 10 ms was a native-code number and keeping it would have made the budget aspirational rather than binding
Snapshot write (encode + deflate + digest) both ≤ 750 ms server-side, ≤ 1.5 s in a browser tab (was ≤ 500 ms) relaxed, honestly. Interchange compression is deflate rather than zstd (AR-560) and the digest is pure TypeScript

Both relaxed budgets are off the interactive path — TurnHash runs once per turn and snapshot writes are policy-driven (AR-660) and MAY be performed on a worker — so the cost lands on background work rather than on the player. If profiling shows the digest dominating desync checking at scale, the escape hatch is a pinned WASM hash module used identically in browser and Node; that is deliberately not specified here, because it would put a WASM dependency back on the rules path that AR-850 just removed. See open question 16.

AR-770 Headless replay throughput, and what it does and does not include. Two figures are normative, measured warm under Node 22 on REF-HW in AR-760's simulation-only configuration (they drive verification, CI, and the AI farm):

(a) Order-application throughput MUST be ≥ 10,000 orders/second on the reference game — that is ≤ 100 µs mean per order, which is exactly AR-760's simulation-only per-order row and no longer a second, tighter claim about the same work. It is measured over order application alone and explicitly excludes the per-turn Reckoning (AR-760), TurnHash (AR-760, AR-530), snapshot writes (AR-660), and container I/O. Those are real costs and they dominate; (b) exists so that excluding them here is a decomposition rather than a dodge.

(b) A full replay of the reference game MUST complete in ≤ 8 minutes. The reference run is the recorded 500-turn-round log of the AR-760 fixture: 8 players × 500 rounds = 4,000 player-turns, and ≈ 200,000 orders (the order count AR-610's ≤ 32 MB container implies at ≤ 32 bytes per record). At the ceilings of (a) and AR-760 that is:

Component Arithmetic At the ceiling
Order application 200,000 × 100 µs 20 s
Per-player Reckoning 4,000 × 60 ms 240 s
TurnHash per player-turn 4,000 × 25 ms 100 s
Total 360 s = 6 min

The 8-minute budget is that 360 s with a third of headroom for container decode, snapshot verification (AR-670), and measurement noise. Two consequences are normative rather than advisory: order throughput is 6% of a full replay at the stated ceilings, so the Reckoning is where replay optimization belongs; and AR-980 MUST gate (b) as well as (a), because a build that meets 10,000 orders/second can still miss the replay budget by regressing upkeep alone.

The AR-960 corpus replays game-parallel — games share no state (AR-730) — so corpus wall-clock in CI is a function of available workers and of the corpus's map-tier mix, not of the serial sum of (b); the reference game is a benchmark fixture (AR-980) and a nightly farm entry (AR-970), not a per-pull-request gate on its own.

Budget (a) is the reason AR-080 mandates a Math.imul-based fast path for fixed-point multiply and AR-350 forbids bigint on the draw path: a bigint allocation per arithmetic operation would not meet it.

AR-780 The 60fps+ client promise (00-overview.md pillar 2) is served by the core exposing read-only, allocation-light view queries: per-frame view reads MUST NOT allocate on the hot path, and the simulation MUST run in a worker, never on the browser's main thread, so that a long Reckoning or snapshot write cannot drop a frame in the Phaser map layer or block DOM interaction. View state is double-buffered; the render thread reads the published buffer and MUST never block on the sim worker longer than 1 ms.

Rationale: in the withdrawn stack a long sim stage on the client stalled the engine's frame loop. Here the failure mode is worse — the main thread also runs the entire React UI — and the fix is cheaper, because the core has no DOM or engine dependency (AR-010) and therefore runs unmodified in a worker.

AR-790 Scaling degradation MUST be graceful and observable: the core exposes per-phase timing counters; exceeding a budget logs a structured performance event (consumed by CI gates, §14, and telemetry per 08-services-platform.md).

11. Headless runner: @everylastcity/cli and server embedding

AR-800 @everylastcity/cli is a cross-platform Node 22 console front-end over @everylastcity/core, runnable via npx without a global install, and MUST ship in the official Docker image beside @everylastcity/server (design brief). Everything a GUI can do to a game's state, the CLI can do headlessly. It MUST depend on the core only through the public surface of AR-030 — no privileged back door — so that any determinism defect the CLI can reach, a player can reach too.

AR-810 Required verbs (stable, scriptable interface; --json emits JSON-lines): new (setup file/flags → container), apply (orders from file/stdin), run-ai (play N turns with named AI plugins), replay (with --verify re-derivation + hash checks per AR-570/670, and --trace-draws emitting the AR-380 draw trace), hash (print Turn/State hashes), snapshot, export/import (canonical JSON, AR-620), validate-content (unit set/scenario schema + reference checks), migrate (§13), benchmark (the AR-760 suite and both AR-770 figures, reporting warm and cold per AR-705 and the AR-770(b) component breakdown), farm (AI-vs-AI batches, §14), diff (state diff between two containers/turns, and draw-trace diff for divergence localization).

AR-820 Exit codes: 0 success; 1 invalid input/validation failure; 2 determinism/integrity failure (hash mismatch, chain break); 3 environment error. CI treats 2 as build-breaking.

AR-830 @everylastcity/server MUST embed the core in-process for authoritative games (fog enforcement, anti-cheat) — the same package at the same version the client imports (AR-300), never a reimplementation — and MUST be able to delegate to CLI-equivalent worker processes for farm/verification workloads; in relay-only mode the service sequences, durably stores, and integrity-checks the order log without simulating it — it never constructs game state, and fog enforcement falls to the designated host client (05-multiplayer.md §6, MP-340/MP-360). Relay-only is an order/event relay, not a file pass-through; whole-container pass-through is the separate bundle drop-box of MP-880.

Whatever the transport (real-time room, HTTP async, bundle), the service's interaction with the core is exactly AR-030's three mutation entry points plus read-only views: orders in, events and view deltas out (AR-240, AR-500). A transport MUST NOT mutate state by any other route, and MUST NOT hold a second, transport-owned copy of game state that could diverge from the log.

AR-840 The CLI MUST run AI-vs-AI games with zero human seats, unattended, to a terminal condition or a turn limit, with no prompt of any kind — which the turn model already guarantees, since nothing between the close of an Orders phase and the next one asks anybody anything (10-turn-model.md TM-050). Snapshot and flush policy in such a run is AR-660's, configured per run rather than special-cased; a farm run (AR-970) SHOULD flush at a cadence that keeps a crashed run's minimized repro bundle cheap to produce.

12. Runtime targets and the WASM plugin boundary

The direction of this section is inverted from the withdrawn plan. There, the core was C# that had to be compiled to WebAssembly to reach the browser, and this section was a list of concessions to that compilation target. Here the core is TypeScript that the browser and Node both execute natively, and WebAssembly appears in exactly one place: as the sandbox format for third-party plugins.

AR-850 The core runs natively; it is not a WASM module. @everylastcity/core MUST execute as ordinary JavaScript in V8 under Node 22 and in the browser's own engine, and MUST pass the full determinism suite on every runtime of AR-070. It MUST NOT be compiled to WebAssembly, and no rules computation may depend on a WASM module — the plugin sandbox of AR-060/AR-855 is the sole WASM surface in the system, and it sits outside the rules path by construction.

Constraints binding on all core code, restated for this target:

Two constraints of the withdrawn plan are simply gone, and this is the clearest engineering gain of the migration: the 32-bit address-space cap (≤ 4 GB) was an artifact of the .NET WASM runtime and no longer binds — the practical ceiling is now the host's own memory policy (AR-720) — and the "build the entire client twice, once for Godot and once for the browser" obligation disappears, because the browser build, the Tauri desktop build, and the Tauri mobile build are the same bundle over the same core.

AR-855 The WASM plugin host. The host that runs AI and world-builder plugins (AR-060, 07-modding-content.md MOD-590/MOD-600) MUST be built on the standard WebAssembly API, which browser and Node both provide, so exactly one host implementation exists and a plugin behaves identically wherever it runs.

The host is @everylastcity/client or @everylastcity/server, never @everylastcity/core. The core is a pure package with no DOM, no Node built-ins — node:worker_threads among them — no ambient clock and no I/O (AR-010, AR-085; AR-050's injected Clock is metadata-only and unreachable from rules code), so it can neither instantiate a module, nor own a worker boundary, nor apply the instrumentation of (c). The host owns all three and passes what a plugin produced into the core as ordinary data: orders through applyOrder (AR-030, AR-240), a generated map through createGame (AR-400, AR-640). Nothing in this section places a WASM dependency on the rules path (AR-850). Normative constraints:

(a) Instantiation is from validated bytes the host supplies; the import object exposes exactly the ABI of 07-modding-content.md and nothing else — no clock, no entropy, no I/O, no host callback that can observe scheduling. Plugin randomness comes only from the derivation of AR-370 and, for worldgen, from AR-400.

(b) Plugins run in a dedicated worker, never on the sim context or the render thread, and the host MUST be able to terminate that worker unilaterally. Memory is capped by the declared WebAssembly.Memory maximum.

(c) The deterministic execution budget is metered fuel. Wall-clock is a watchdog only, and wall-clock is not a rules input.

The deterministic budget MUST be fuel, measured in instrumented units and produced by the deterministic, versioned bytecode instrumentation transform that 07-modding-content.md MOD-610 and 06-ai.md AI-410 specify, applied to the module before it is instantiated. Fuel is the only budget whose exhaustion may influence a plugin's output; exhaustion traps the instance. The instrumented module is a derived artifact and MUST NOT be published or hashed (MOD-615) — a plugin's identity remains the bytes in its archive, so a host may re-instrument or re-cache without changing what the plugin is.

The premise this clause used to rest on is withdrawn. It read that the browser's WebAssembly API offers no deterministic instruction budget and therefore mandated an ABI-level call/allocation count as the deterministic step limit. That was wrong in its conclusion, not merely stricter than its partners: the absence of an engine-level budget is answered by moving the meter into the artifact, where a build-time pass injects decrement-and-check operations that behave identically under the standard API in a browser tab, a worker, and Node. An ABI-level counter also meters at a different cut-off point, so it would produce different plugin output from the same inputs — and 06-ai.md AI-420 admits exactly two interrupt mechanisms, of which it is not one. An ABI-level call or allocation count MUST NOT be substituted for fuel, and MUST NOT be offered as an alternative.

Wall-clock survives only as a non-deterministic watchdog against a wedged or infinite-looping plugin, and against a defect in the host itself. It MUST NOT be the budget that shapes results, and its firing is a plugin fault, not a budget outcome: the host terminates the worker of (b) and recovers through the fault path of 06-ai.md AI-240, which owns the fallback behaviour and the repeated-fault threshold — 03 cites it and MUST NOT state a threshold of its own. The recovery is a fallback decision entering the log as an ordinary order carrying Issuer = Authority(PluginFallback) (AR-260), so the log stays replayable on a machine where the watchdog would never have fired. A replay MUST NOT re-invoke plugin code (AR-370 rationale, 06-ai.md AI-350), which is what makes both the fuel trap and the watchdog safe for the game record.

No host import may hand plugin code an observable wall clock, including as a field of a budget query: a budget import that reported elapsed milliseconds would be a covert clock, and a plugin that read it could make its output depend on the machine it ran on while still passing every determinism check the host applies to the module. The AI import surface is owned by 06-ai.md AI-200; this requirement binds any host import that 03's plugin host exposes.

Rationale: a wall-clock budget makes how much work a plugin completes a function of the machine it runs on. For an AI player that means a faster computer plays a stronger game — a fairness defect in multiplayer, and an irreproducibility defect in the AR-970 AI-vs-AI farm, where the whole point of a run is that someone else can repeat it. The honest cost of fuel is not a capability loss but a build obligation: the meter is now a transform the project owns, versions, and tests (open question 17), rather than a runtime switch someone else maintains.

(d) Loading a plugin requires wasm-unsafe-eval in the client's CSP. That is a stated, accepted cost of shipping sandboxed plugins to a browser, and it MUST be scoped to the client's plugin-hosting origin rather than granted page-wide.

AR-857 The plugin execution configuration is recorded setup data. Fuel counts are comparable between two hosts only if both metered the same way, so 06-ai.md AI-410 and 07-modding-content.md MOD-610 both require the metering parameters to be recorded with the game configuration. 03 owns where that record lives. The container MUST carry a PluginRuntimeConfig (AR-150) recording at minimum:

Field Content Why it is recorded
fuelBudgets The per-context fuel limits in force, keyed by the execution contexts of 06-ai.md AI-400 (which owns the values and their defaults) AI-410 makes fuel-per-turn limits part of the recorded configuration
instrumentationTransform Transform id and version of the fuel instrumentation pass AI-410 and MOD-610: a different pass meters at a different point, so a fuel-metered result from a different version is not comparable
featureGateVersion Version of the permitted-WASM-proposal list validated at load MOD-600: widening the list later changes what a recorded game can replay
enforcement Which enforcement actually applied — fuel-metered, or uninstrumented under the watchdog of AR-855(c) AI-410 requires a later reproduction attempt to be able to tell whether a run is verification evidence

It MUST be written into setup.bin while manifest.phase == SignIn and into initial.bin thereafter (AR-560, AR-567), inside the hashed setup, exactly as AR-490's AI Full Vision option is. It is present iff the game ran any plugin at all — an AI-controlled position (06-ai.md) or a world-builder generation (AR-400) — and readers MUST reject a container that ran one and lacks it. A scenario-sourced game (AR-640) with only human positions runs no plugin and carries none.

PluginRuntimeConfig is not a fourth version axis, and a change to any of its fields MUST NOT bump RulesVersion (AR-880). Plugin output reaches the simulation only as logged orders (AR-250, 06-ai.md AI-350) and a replay never re-invokes plugin code, so a container recorded under one transform version replays bit-exact under another. What the record buys is narrower and worth stating exactly: it is the evidence needed to say whether a re-run of the plugin itself is comparable to the original — which is AI-410's requirement, not a replay requirement. Correspondingly, MOD-615 keeps the instrumented module out of every digest; only the transform's identity is recorded here, never its output.

AR-860 Workers and shared memory are feature-detected optimizations. Where web workers or worker_threads are unavailable, all AR-730 parallel stages MUST run sequentially with identical results (AR-120). SharedArrayBuffer and Atomics require cross-origin isolation (COOP/COEP headers) in browsers; the client MUST detect their availability and fall back to transferable ArrayBuffers, again with identical results. There is no SIMD path: JavaScript exposes none, and the hashing and fog costs that WASM SIMD would have absorbed are budgeted honestly instead (AR-130, AR-760).

AR-870 One client, one core. The browser client executes the same core package for prediction/preview; authoritative simulation for fog-enforced online games remains server-side (AR-300, AR-590). Hot-seat and solo in-browser run the full authoritative core locally under the same determinism contract, in a worker (AR-780). The Tauri 2 desktop and mobile clients wrap the same web client bundle and therefore run the same core in the platform webview — so a rules defect reproduces identically on all six platforms, and a fix ships once.

Webview engines are a supported runtime and MUST be gated as one (decision). Every platform webview Tauri 2 targets — WebView2 on Windows, WKWebView on macOS and iOS, WebKitGTK on Linux, Android System WebView — MUST satisfy AR-070's bit-identity on the determinism corpus, and the AR-960 matrix MUST include the oldest Android System WebView version the product declares support for, not merely a current one. A webview version that fails the corpus MUST be refused at startup with a named error stating the minimum, rather than allowed to play and desync.

Rationale for making this a requirement rather than a verification task: mobile webview versions are not under the product's control, which means "we checked once" is not a state this can be left in. The determinism claim either has a permanent gate covering those engines or it does not cover them at all, and a player whose phone shipped a divergent engine finds out by losing a correspondence game. What remains genuinely open is which minimum version to declare — an engineering measurement, tracked as open question 18.

13. Versioning and migration

AR-880 Three independent version axes, all recorded in every manifest: FormatVersion (container encoding; readers reject a higher major, ignore-and-preserve unknown entries within a major), EngineVersion (the SemVer version of the @everylastcity/core package, plus the build digest of AR-300), and RulesVersion (bumped by any rules-observable behavior change, including RNG discipline changes per AR-350 and fixed-point rounding changes per AR-080).

A dependency upgrade inside the core is a candidate rules-observable change and MUST be treated as one until the determinism corpus proves otherwise: the AR-960 job MUST run against the pinned lockfile, and the lockfile MUST be committed. Rationale: in a package ecosystem, "we changed nothing" and "the dependency tree changed" are routinely the same commit.

AR-890 Replay pinning: a container MUST replay bit-exact under the RulesVersion it was recorded with. The core package MUST therefore ship the historical rules modules for at least the current and previous two RulesVersions side by side, selected at runtime by the container's recorded version — not reconstructed from version control at load time. Opening an older container with a retired RulesVersion MUST offer sealed promotion: the latest verified snapshot becomes a new initial.bin under the current version (recorded as a branch, AR-600) — never silent reinterpretation of an old order log under new rules. Retired versions' fixtures MUST remain in the corpus of AR-910 so a promotion path is never the only evidence an old container still loads.

AR-900 Content versioning: unit sets and terrain sets are referenced by {key, version, SHA-256} (AR-560), and the unit set's identifier and content hash are part of the canonical game hash so that two clients running different versions of a set desync immediately and loudly (02-units-and-industry.md US-2090). A running game embeds a copy of its content bundle in the container so replays never depend on workshop availability. An editor save MUST increment the set version — a set whose content hash changed without its version changing is the one case the {key, version} pair cannot distinguish; 07-modding-content.md owns distribution.

AR-910 elc migrate (the @everylastcity/cli verb, AR-810) MUST upgrade containers across FormatVersions losslessly (byte-preserving unknown entries) and report exactly what changed. A frozen corpus of containers from every released FormatVersion MUST round-trip in CI forever.

AR-920 Wire/view schemas (orders, events, view deltas) carry a schema version; a server MUST reject, with a clear error, clients whose schema major differs — never guess (mixed-version async games are 05-multiplayer.md's compatibility problem, built on this signal).

14. Automated testing strategy

Testing is load-bearing infrastructure in this stack, not hygiene. Determinism is not guaranteed by the language, so the suite is the only thing standing between a stray float and an unreproducible save (§2); and the implementation is written primarily by AI agents, so verification, not review, is the safety net (00-overview.md §8). Anything an agent can get subtly wrong MUST have a mechanical check, and coverage of rules code is a gate, not a metric.

There is nothing to validate against, and the strategy is built accordingly. The previous revision of this section specified golden-master validation against another product: fixtures transcribed from a manual, a written protocol for scripting scenarios against another game's binary, and flags meaning check this against the original. That whole apparatus is void — the design is clean-room and no other product is a reference (docs/design/00-direction.md §2; 00-overview.md OV-040, OV-050). Nothing is lost by its removal, because it was never the thing that would have proved this engine correct.

Five mechanisms replace it, and between them they carry the entire correctness argument:

Mechanism What it proves Where
Property tests The engine's own stated invariants hold over generated inputs, not merely over the cases someone thought of AR-950
Continuous invariant assertions The state is never internally inconsistent at any point during a Cascade or Reckoning, not only at the boundaries AR-945, AR-955
Replay determinism The same initial state and order log produce the same game, every time, on one machine AR-950(a), AR-960(a)
Cross-platform hash agreement …and on every other machine, engine and worker count. Built and passing (AR-960) AR-960
AI-vs-AI self-play soak The engine survives millions of turns nobody hand-wrote, and the AI's claimed strength is measured rather than asserted (00-overview.md OV-120) AR-970

Golden masters survive, with their meaning changed. A golden master here is a recording of our own engine's output at a named RulesVersion, committed so that an unintended change shows up as a diff. It is evidence of stability, never of agreement with anything outside this repository, and a golden master that disagrees with the engine is resolved by deciding which is right — not by assuming the recording is authoritative. AR-930 states the discipline.

Rationale: validating against another product answers "does it match?", which was never the interesting question and is now not a question at all. The interesting questions are "does it do the same thing twice", "does it do the same thing on your machine", "does it hold its own invariants under inputs nobody chose", and "does it survive a million turns of adversarial play" — and every one of those is answerable by machinery this project owns, runs on every pull request, and cannot be talked out of.

AR-930 Golden masters, recorded from this engine. A golden master MUST be a committed recording of this engine's own output at a named RulesVersion, and MUST NOT be described, used, or justified as agreement with any other product. Its purpose is to make unintended change loud: a diff against a golden master is a question — did we mean to change this? — not a verdict.

(a) What MUST be goldened, at minimum:

Recording Why this one
Canonical encoder byte vectors, per encoded type (AR-620) The encoding is what every hash is computed over; a silent layout change invalidates every stored game
The ordered TurnHash sequence and final StateHash of every corpus game (AR-960(c)) The single most sensitive detector of an unintended rules change
The ActivationOrderHash sequence of every corpus game (AR-130, 10-turn-model.md TM-2370) Catches an initiative or ordering change before state diverges, which names the bug
State snapshots between consecutive Reckoning steps for at least one corpus game (TM-1930) A step-ordering or fusion defect (AR-230) that a whole-turn hash reports as "something moved"
Each player's compacted observation stream for a recorded Cascade (AR-295) The fog surface, where a leak is a diff rather than a crash
The AdvisoryHash sequence (AR-130, 13-command.md CM-190) What players are told — a Dispatch that stops mentioning something is otherwise invisible to CI
Worked examples that other documents state as normative test vectors (for example 13-command.md CM-1200) Where another document has already committed to an exact answer, this engine MUST produce it

(b) Recording a golden master MUST be a deliberate act. Each MUST name the RulesVersion, commandLogicVersion and advisoryVersion it was recorded under, and the corpus game or fixture it came from. A golden master MUST NOT be regenerated to make a red build green: updating one requires either a version bump under AR-880 or an explicit reviewed justification recorded in the commit — the same gate AR-960(e) applies to expected hashes, for the same reason.

(c) A golden master is not evidence that a value is correct. It is evidence that the value has not changed. Whether a constant is right is settled by the requirement that owns it and by the self-play measurement of AR-940 — never by the existence of a recording.

Rationale: the retired form of this requirement was a transcription exercise against another product's manual, and its authority came entirely from that product. With the fidelity premise withdrawn it would have had none: a fixture asserting a number because a manual said so, in a game that shares no numbers with that manual, is a test of nothing. Recording our own behaviour keeps everything the mechanism was good at — catching the change nobody meant to make — and drops the only part that depended on a reference we do not have and do not want.

AR-935 Requirement-to-test traceability (AI-authorship constraint). Every requirement in this specification that constrains core behavior MUST map to at least one named test. Normatively:

(a) Test titles MUST embed the requirement IDs they cover, in a machine-readable form — describe('AR-080 · fixed-point overflow throws', …), it('GR-340 · a unit may not enter a tile it cannot occupy', …) — with IDs matching [A-Z]{2,3}-\d{3,4}.

(b) A CI job MUST build a traceability index by extracting every requirement ID from the specification files and every ID referenced in a test title, and MUST fail the build on any core-constraining requirement with zero covering tests. The index MUST be published as a build artifact so a reviewer — or an agent — can go from a requirement to its evidence in one step.

(c) A requirement that genuinely cannot be tested (a process obligation, a documentation duty) MUST carry an explicit no-test-needed: annotation with a reason, recorded in the index. Silence is a failure; an annotation is a decision.

(d) A fixture that encodes a constant — a threshold, a cost, a cadence, a default — MUST cite the requirement that owns that constant, and MUST cite nothing else as its authority. There is no external source to cite (00-overview.md OV-040), so a disagreement between a fixture and the engine is always traceable to one requirement in one owning document, and is settled there.

Rationale: an agent asked to implement AR-080 can be pointed at the requirement and told to make its tests pass; an agent asked to change AR-080 can be shown exactly what its change will break. Without the index, neither is possible, and the specification degrades into prose the implementation drifts away from.

AR-940 Tuning constants: named, owned, and measured — never flagged for checking against something else. 00-overview.md OV-040 permits a requirement's number to be a considered guess awaiting self-play data, provided it states a concrete value and says so. This requirement is how such a value is handled mechanically.

(a) A constant whose owning requirement declares it provisional MUST be declared in code as a named tuning constant in one registry module per owning document, never inlined at its use site, and MUST carry the owning requirement's ID.

(b) The registry MUST be enumerable, and a CI job MUST publish the list of live tuning constants alongside the traceability index of AR-935(b), so that "what in this engine is still a guess" is answerable in one step by a reviewer or an agent.

(c) Every tuning constant MUST be exercised by the self-play pipeline of AR-970, and a difficulty tier or balance claim that rests on an unmeasured constant MUST NOT ship (00-overview.md OV-120).

(d) Changing a tuning constant is a rules-observable change: it MUST bump RulesVersion (AR-880) and MUST take the golden-master update path of AR-930(b). A tuning constant is provisional in the design, not in the record.

(e) A fixture MUST NOT carry a flag meaning "check this against another game." There is nothing to check against (00-overview.md OV-050). A value is either a decision with a stated rationale in the requirement that owns it, or a tuning constant under this requirement, or an entry under some document's ## Open questions. No fourth state exists.

Rationale: the retired form of this requirement asked for a written protocol to script scenarios against another game's binary and compare observed values, which the clean-room rules forbid outright and which would have been the project's only remaining reason to obtain that product. Replacing it with a registry is a strict improvement even ignoring the premise change: the old mechanism could only answer "does this match?", while this one answers "is this good?" — which is what a provisional number is actually waiting on.

AR-945 Continuous invariant assertions. The core MUST define its structural invariants as executable predicates and MUST check them, in a debug and CI build, at every phase boundary and after every activation within a Cascade — not merely at the start and end of a turn. A violated invariant MUST throw a typed CoreInvariantError naming the invariant and the activation or step that broke it (AR-055).

The invariant set MUST include at minimum: every unit has a complete, valid order stack (AR-205, 13-command.md CM-080); every entity referenced by ID exists and is owned by exactly one player (01-game-rules.md GR-030); no unit occupies a tile its domain or stacking rules forbid; the activation roster is frozen for the duration of a Cascade and no unit activates twice (10-turn-model.md TM-1200); no tile occupancy or ownership field is written during the Reckoning (AR-230); every Int in state satisfies AR-080's range invariant (AR-955 is the exhaustive form of this one); and every player's knowledge is a subset of what the observation rules would grant (01-game-rules.md GR-1180).

Rationale: a whole-turn hash tells you that the turn's output changed. An invariant assertion tells you which activation broke what, which is the difference between a bisect over an order log and a bug someone can read. The cost is a debug-build check per activation, which is exactly the kind of cost the AR-770 budget explicitly excludes from the shipping path.

AR-950 Property-based tests (fast-check or equivalent, seeded from a recorded seed so a failure is reproducible and the shrunk counterexample is checked in) MUST cover at minimum: (a) replay idempotence — replaying any generated log twice yields identical hashes; (b) serialization round-trip — state → canonical bytes → state is identity; (c) fog non-leakage — mutating any state invisible to player P leaves PlayerView(P) and P's event stream byte-identical; (d) incremental fog ≡ full recompute (AR-480); (e) order totality — random/fuzzed orders never throw (AR-140); (f) order supersession — for any generated sequence of order records from one player targeting the same unit and stack level, the state after applying all of them is identical to the state after applying only the last (AR-330, 10-turn-model.md TM-230), and no accepted order record changes any player's knowledge, consumes any draw, or emits any Cascade event (AR-220, TM-340, TM-350); (g) conservation — a unit enters play only through a UnitCommissioned or scenario-placement event and leaves it only through a destruction or disband event, so no generated log produces a unit from nowhere or loses one silently; (h) view checkpoint + deltas ≡ fresh view; (i) AI neutrality — a log recorded from a game with AI players replays to identical Turn/State hashes with AI code never invoked and rng.draw never called (AR-370, 06-ai.md AI-350), and no ai/ container entry participates in any hash; (j) audit-log neutrality — containers differing only in audit.log replay to identical hashes (AR-563); (k) fixed-point algebra — the Math.imul fast path and the bigint reference path of AR-080 agree over a generated domain covering both range extremes, both signs, zero, and the powers of two either side of the 32-bit lane boundary, and every operation either returns an in-range Fixed or throws (never wraps, never saturates, never yields NaN); (l) insertion-order independence — the AR-090(e) shuffle harness: a semantically identical state built in a shuffled construction order replays to identical TurnHash/StateHash sequences; (m) encoder strictness — the canonical encoder of AR-620 rejects NaN, ±Infinity, −0, non-safe-integer numbers, out-of-range fields, and unpaired surrogates rather than encoding them, and its output round-trips to an identical state; (n) PRNG conformance — the hi/lo PCG32, SplitMix64 and Lemire implementations agree with their bigint references over a long run and reproduce the published reference vectors (AR-350, AR-360); (o) trace neutrality — enabling the AR-380 draw trace changes no hash;

(p) cross-player commutation — shuffling a turn's order log across players while preserving each player's internal sequence leaves TurnHash, StateHash and ActivationOrderHash unmoved (AR-225, 10-turn-model.md TM-240, and hazard 7 of TM-2360). This is distinct from (l), which shuffles construction order; this one shuffles submission order, and it is the property that makes concurrent ordering fair rather than a race to submit;

(q) Cascade and Reckoning purity — running the Cascade and Reckoning twice from the same committed close-of-Orders state produces identical state, identical event streams and identical per-player observation streams, and depends on no value outside that state and the ruleset (AR-210, 10-turn-model.md TM-040);

(r) recomputation, not recording — a container whose optional events.log is deleted replays to identical hashes and identical event streams, and one whose events.log disagrees with re-derivation fails verification rather than being believed (AR-250, AR-390, 10-turn-model.md TM-310);

(s) observation-stream compaction — holding one player's observations fixed and varying everything else in the Cascade leaves that player's stream byte-identical, including its length, so no field varies with unobserved activity (AR-295, AR-495, 10-turn-model.md TM-2160);

(t) activation-order agreement — two peers computing the frozen roster from the same committed state produce the same ActivationOrderHash, and a roster with heavy initiative ties orders identically on every runtime, terminating in the creation-sequence tiebreak (AR-130, 10-turn-model.md TM-2370, and hazards 1 and 2 of TM-2360);

and (u) map-tier extremes — (a) through (t) hold at both bounds of 01-game-rules.md GR-120's [16, 2048] range, not only at the reference tier, since the smallest and largest maps are where index-width and chunking assumptions break.

AR-955 No-float runtime proof. Static lint (AR-085) cannot see a float that arrives through a computed path, so a dedicated CI job MUST run the whole determinism corpus against an instrumented core build in which every write into simulation state and every value entering the canonical encoder is asserted to satisfy the AR-080 invariant (Number.isSafeInteger, and for Fixed, (raw | 0) === raw). Any violation fails the build with the writing field, the requirement ID, and the order sequence that produced it. The instrumented build MUST be generated from the shipping source — never maintained by hand — and the determinism trap of AR-085 MUST be installed for the run.

Rationale: the failure this exists to catch is a single division written with / instead of idiv in a branch that only fires under an uncommon rule toggle. Lint would not see it if it were written as a helper call on a mistyped value; the corpus does, because the value reaches state.

AR-960 Cross-platform determinism CI (Phase 0 deliverable, and a required merge gate). This job — its matrix (b), its comparison (c) and its localization machinery (d) — together with the lint configuration of AR-085, MUST exist, MUST be wired as a required status check, and MUST be green on the seed fixture of (a) before the first rules module merges. It MUST run on every pull request touching packages/core and on every merge to the default branch, MUST be a required status check, and MUST NOT be skippable by label, commit message, or manual override.

What Phase 0 gates is the machinery and the merge block, not the corpus size. The ≥ 100-game corpus of (a) cannot exist before the rules code that plays those games. A Phase 0 gate stated so that only the last phase can satisfy it is a gate nobody installs, which would forfeit the one thing §2 says cannot be retrofitted. The obligation is therefore split into a reachable existence stage and a coverage floor that grows with the code, staged against the Industry ladder (02-units-and-industry.md US-810) rather than against a set of rulesets that no longer exists — the game ships one unit set (US-1340), and its complexity escalates through the four Industry tiers, so that is what the corpus grows along.

(a) Corpus — a seed fixture first, then a staged coverage floor.

Seed fixture (Phase 0, blocking). A minimal fixture MUST be committed and green before the first rules module merges: a hand-authored initial.bin, a short order log, and committed expected hashes, exercising encode → replay → hash → compare → bisect end to end on every leg of (b). It requires no rules module, and its purpose is to prove the harness and the merge block work while the amount of code behind them is zero.

Coverage floor (staged, each stage blocking its own milestone's exit). The corpus MUST reach ≥ 100 games spanning every Industry tier, every optional rule module in SetupConfig, every map tier and topology (01-game-rules.md GR-190), every victory Track (14-victory.md §8), and at least one game per AI-bearing configuration (AR-950(i)). It MUST reach that floor along these milestones, each blocking its own stage:

Milestone Cumulative corpus Coverage added
First playable turn loop ≥ 25 games Industry 1 only — the complete simple game (US-1360 to US-1410); every map tier including both bounds of GR-120; all three topologies; every Track expressible at Industry 1
Full roster and command layer ≥ 60 games Industry 2–4; every optional rule module; scenario-sourced games; every Posture and Sanction combination reachable by a Doctrine (13-command.md CM-770)
AI seats ≥ 80 games At least one game per AI-bearing configuration (AR-950(i)), including the disclosed full-vision option of AR-490
Feature complete ≥ 100 games Every Track including those needing Landmarks and Regions; every economic brake exercised at its threshold (12-economy.md EC-030)

The corpus MUST additionally contain, from the first milestone onward, one dedicated game per hazard in 10-turn-model.md TM-2360's table — that document names eight ways this specific turn model fails, and each is a corpus entry rather than a unit test, because each is only visible across a whole turn.

The corpus is versioned in-repo and grows by AR-1000; a game is never removed, only superseded. A regression in coverage — a milestone met and later un-met — MUST fail the build exactly as a hash divergence does. 09-roadmap.md owns which delivery phase each milestone lands in; this document owns only that the milestones exist and block.

(b) Matrix. Every corpus game replays on: Node 22 LTS × {win-x64, linux-x64, linux-arm64, macos-arm64}; Chromium, Firefox and WebKit driven headlessly on linux-x64 and macos-arm64; and Node 22 at 1 worker and at N workers (AR-120). It MUST additionally include one adjacent Node major (the next LTS in pre-release, and the previous LTS while supported), because the risk this stack carries and the withdrawn one did not is that the engine changes underneath a fixed binary-identical source.

(c) Comparison. Each leg emits, per game, the ordered sequence of every TurnHash, the ordered sequence of every ActivationOrderHash (AR-130), the final StateHash, the AdvisoryHash sequence, and the AR-380 draw-trace digest. A single canonical expected-hashes file is committed to the repository; every leg diffs against it. Any divergence fails with exit code 2 (AR-820), which is build-breaking.

The ActivationOrderHash sequence is emitted first and compared first, because it diverges before any state does: a leg that agrees on activation order and disagrees on state has a rules defect, while a leg that disagrees on activation order has an Initiative or ordering defect, and knowing which before reading a diff is most of the debugging (10-turn-model.md TM-2370).

(d) Localization. On divergence the job MUST automatically bisect the order log to the first diverging Seq, diff the draw traces to name the first divergent draw site, and upload the diverging container plus both traces as build artifacts. A determinism failure that reports only "hashes differ" is not actionable, and under AI authorship an unactionable failure gets worked around rather than fixed.

(e) Expected-hash changes are reviewed like rules changes. Updating the committed hashes MUST require a RulesVersion bump (AR-880) or an explicit, reviewed justification recorded in the commit; regenerating them to make a red build green is forbidden.

AR-970 AI-vs-AI self-play soak — the engine's endurance test and the AI's evidence. Nightly, ≥ 500 headless games via elc farm (AR-810), across a matrix of SetupConfig parameter combinations × map tiers and topologies × seeds × opponents. It serves two purposes that share one machine, and both are normative:

(a) As an engine soak, it MUST detect and file: crashes; determinism failures; CoreInvariantError throws (AR-945); stalled games — no state-changing event for a configurable turn window, which is how a rules deadlock presents; a game that reaches a turn ceiling without any Track advancing (14-victory.md's Ebb exists to prevent exactly this, so a game that stalls anyway is a defect in one of the two documents); rule-invariant violations; and budget regressions. Every failure artifact MUST be a minimized replay container, produced by automatic bisection over the order log, and MUST land in the corpus under AR-1000.

(b) As the AI's evidence, the opponent matrix MUST include, beyond the shipped opponents playing each other: prior versions of the shipped opponents, so a regression in play strength is caught as a change in result rather than in a review; and deliberately adversarial scripted opponents, at least one per known exploit class, because self-play alone measures an agent only against opponents that share its blind spots and will report perfect health while a cheap repeatable exploit is live. 00-overview.md OV-120 forbids shipping a difficulty tier whose claimed strength this pipeline has not measured, and this requirement is the pipeline it names. 06-ai.md owns what the opponents are and what "strength" is measured as; this document owns that the harness exists, runs nightly, and produces a minimized repro for every failure.

Rationale for treating one farm as two obligations: they want the same infrastructure and would otherwise be built twice. A soak run wants many games, wide parameter coverage, and a crash report; an evaluation run wants a controlled matchup and a win rate. The difference is what is recorded, not what is executed — so the farm records both, and the AI's strength claim and the engine's endurance claim are produced by the same nightly job.

AR-980 Performance gates: elc benchmark runs the AR-760 suite and both figures of AR-770 in CI on pinned hardware and a pinned Node version; a > 10% regression on any budgeted warm metric fails the build. AR-770(b), the whole-run replay figure, MUST be gated in its own right and not inferred from the per-order metrics: at the stated ceilings order application is only 6% of a full replay, so a build can hold AR-770(a) exactly and still blow the replay budget by regressing the Reckoning alone. The benchmark MUST report the AR-770(b) component breakdown, so a regression names the component that moved. Per AR-705 the job MUST also record cold-start figures and MUST raise a non-blocking alert on a > 25% cold-start regression, because V8 tiering means a change can leave the warm number untouched while doubling the time to a player's first action. Memory budgets (AR-720) are asserted with heap and ArrayBuffer accounting, reported separately, since AR-710 deliberately moves bulk state out of the traced heap and a regression that moves it back would otherwise hide inside a single total.

AR-990 Coverage and fuzzing: rules modules MUST hold ≥ 90% line and branch coverage, enforced as a merge gate rather than reported as a metric, and a coverage drop MUST fail the build even when the absolute number is still above the floor. A continuous fuzzer feeds malformed containers (truncated, bit-flipped, hash-broken, zip-bomb, entry-name traversal) and hostile order streams into loadFrom/applyOrder, asserting typed failures only (AR-140, AR-570) — never a thrown exception from applyOrder, never an unhandled rejection, and never a silent repair.

Rationale for the gate: under AI authorship an untested branch is not merely unverified, it is unread. Coverage is the cheapest available proxy for "a human or a test has looked at this path", which is why §14 treats it as a gate and not a dashboard.

AR-1000 Every fixed bug that involved simulation state MUST land with a replay-container regression test (the AR-540 repro bundle checked into the corpus, minimized).

Open questions

  1. Where does a player's observation stream live between turns? AR-495(d) and 10-turn-model.md TM-2230 require a player to be able to re-watch the previous Cascade during the following Orders phase, and AR-250 makes the stream derived rather than logged — so re-watching means either holding it in memory for a turn, caching it beside events.log, or re-deriving it by re-running the Cascade from the previous state. All three work; they differ in what a reconnecting client can be given and in what a fog-enforced client is allowed to hold (AR-590). Lean: hold in memory for one turn, re-derive on demand from the authoritative side, and never persist a player-bound stream into a container that another player could come to hold.
  2. Should events.log caching be on by default in saves (faster open/spectate, larger files) or generated on demand? Current lean: on for server-hosted games, off for local saves.
  3. (Dissolved by the turn model; recorded so the question is not re-asked.) This asked how wide the retraction window should be. There is no retraction (AR-330) and no window: an accepted order resolves nothing, so revision is last-write-wins and free until the phase closes (TM-360). What survives is a client-side question 04-ui-ux.md owns — whether batch staging needs a core-provided legality preview over a whole staged set at once, rather than one order at a time, so a player composing thirty orders learns about the illegal one before submitting. That is an API-shape question against AR-490's legal-order query, not a rules question, and it costs nothing in the log either way.
  4. Extreme-tier (2,048×2,048, AR-690) fog memory at the maximum seat count may warrant chunk-paged fog bitsets; decide after profiling against AR-720. The pressure is much lower than the previous revision assumed: 01-game-rules.md GR-030 caps a game at 8 players, not the 16+ the old figure was sized for, and GR-120 caps the map at a quarter of the old area — together a 32× reduction in worst-case fog storage. Re-measure before building anything.
  5. Ed25519 container signing: now mandatory in the turn-bundle role (AR-565, 05-multiplayer.md MP-770) and optional elsewhere. Still open: must workshop-published scenarios and replays be signed too? Interacts with 07-modding-content.md's workshop trust model.
  6. (Reframed by the stack change; the old form is resolved.) The withdrawn plan's "4× single-threaded WASM" multiplier is gone with the WASM compilation target (AR-850). What replaces it is a memory ceiling, not a speed one: AR-720 states that browser and Tauri-mobile clients cannot hold Extreme-tier maps. Open: what is the actual per-platform tier ceiling (measured, not assumed), how does a client advertise it, and does a player on a phone joining a Large-map async game degrade gracefully or get refused at join? Interacts with 04-ui-ux.md's setup screens and 05-multiplayer.md's cross-play promise, which pillar 3 makes non-negotiable.
  7. Does AR-180's "Installations are tile data, not entities" survive everything the rules ask of an Installation? 01-game-rules.md gives Installations owners, construction, capture (GR-500) and destruction, and 02-units-and-industry.md gives the Engineer a buildables set. Storing them in the tile SoA arrays is right for the common case and cheap, but capture and destruction are ownership events on a thing that is not an entity, which means the event catalogue (AR-280) carries InstallationBuilt/InstallationDestroyed against a tile index rather than an ID. Open: does anything need to reference a particular Installation across turns — a Consignment, an Attention Event, a victory site — in a way a tile index cannot express if the tile changes hands? Lean: no, tile index is sufficient and the saving is real; confirm against 13-command.md's Postings and 14-victory.md's Work Sites before the storage is built.
  8. Per-player history series for the client's graphs — city count, unit count, Works income, army strength over time. 14-victory.md VC-250 already retains a Seal record per turn per player, which is hashed state (AR-150) and covers Standing, Tenure and every Track's progress; the graphs a player actually wants are mostly not in it. Open: derive the rest by replay scan and cache in meta/, or widen the Seal record? Lean: derive by scan — a series that is only ever drawn does not belong in the hash, and a replay scan of a finished game is cheap by AR-770.
  9. Audit-log scope on export (AR-563): does a shared replay or bug-report bundle carry the full audit trail (actor identities, rollback reasons, substitution records) or a redacted projection? Privacy against MP-740's permanent-visibility requirement.
  10. Cross-document reconciliation still outstanding: 05-multiplayer.md §10 should point at AR-567's pre-launch sign-in profile as it already does at AR-565's hand-off chain. Separately, 02-units-and-industry.md US-2120 declares every previously-published US-NNN identifier stale and reused, and this document cites several — US-030, US-040, US-050, US-060, US-070, US-120, US-590, US-600, US-640, US-650, US-710, US-810, US-1340, US-1360, US-1410, US-2090. Each was re-resolved against the current document during this revision and each resolves; any future citation of a US-NNN in this document MUST be re-checked the same way rather than trusted from memory.
  11. Does auto-turn resolution (05-multiplayer.md MP-1000) drive the same processing cursor as an interactive turn (AR-275)? It must, if auto-turned and hand-played turns are to produce comparable order logs, but 05 does not say so and the cursor is not itself logged.
  12. AR-370's draw counter is per (player, turn, stream key) and is transient host bookkeeping, so nothing hashed depends on it. The turn boundary resets it cleanly for the replacement paths that operate at one (06-ai.md AI-250 hot reload, 05-multiplayer.md MP-1010 substitution). Open: can any path replace or restart an instance within a turn — a fault fallback (06-ai.md AI-240) that recovers rather than ending the turn — and thereby re-issue counter values already consumed? Current lean: harmless, since no ai/ value reaches hashed state (AR-950(i)) and a replaced AI is not required to play identically; confirm with 06-ai.md, which has a related item open against its own AI-private-seed derivation.
  13. AR-400 requires the worldgen seed of MOD-640 to be one-way derived where the game has AI positions, matching the scope of 06-ai.md AI-195. Should the derivation instead be unconditional, so the host has one code path and the seed value is not evidence of whether a game contains AI players? Lean: yes, unconditional; needs 06-ai.md and 07-modding-content.md sign-off.
  14. AR-540 scopes bug-report export to the container a client already holds, so a fog-enforced participant can attach only their view cache (AR-590). Is a view cache enough to reproduce a simulation defect, or does every fog-enforced bug report need an operator-side capture (05-multiplayer.md MP-1230, 08-services-platform.md) to be actionable? Interacts with AR-1000's requirement that fixed bugs land with a replay-container regression test.

Questions raised by the move to TypeScript (new in this revision):

  1. Is Q16.16 the right single fixed-point representation (AR-080), given that nothing uses it? A power-of-two scale makes the int32 invariant checkable in one instruction; a decimal scale (10⁶) would represent the per-mille constants the rules documents actually write without an exact-rational step. The question has weakened considerably since it was raised: every current rules document forbids Fixed in its own computations (01-game-rules.md GR-060, 02-units-and-industry.md US-030, 10-turn-model.md TM-100, 11-combat.md CB-060, 12-economy.md EC-100, 14-victory.md VC-110), so Fixed has no consumers at all and the choice affects no shipped value today. Lean: keep Q16.16 and keep fractional constants as exact rationals. It still MUST be settled before the first rules module merges, because a subsystem that later needs sub-per-mille resolution inherits whatever is here, and changing it afterwards changes every hash.
  2. Digest cost (AR-130, AR-760). Pure-TypeScript XXH3 and SHA-256 synthesizing 64-bit lanes from 32-bit ones forced TurnHash from ≤ 10 ms to ≤ 25 ms and snapshot write from ≤ 500 ms to ≤ 750 ms. A pinned WASM hash module, identical in browser and Node, would recover most of it — but it would reintroduce a WASM dependency on the hashing path that AR-850 deliberately removed, and a WASM module that must be byte-identical everywhere is a new determinism surface with its own toolchain-reproducibility problem. Decide after profiling the real digest against real reference-game state, not against a synthetic buffer. AR-770(b) now prices this: at the stated ceilings TurnHash is 100 s of a 360 s reference-game replay — 28% — which makes the digest the second-largest component of headless verification and gives this question a concrete figure to be decided against.
  3. (Resolved: the deterministic plugin budget is fuel. What remains open is its cost.) This question previously asked whether an ABI-level call/allocation count was a sufficient deterministic step limit, on the premise that the browser's WebAssembly API supplies no instruction budget. Both halves are now settled against 03's old position: the premise is answered by build-time instrumentation, which puts the meter in the module rather than in the engine, and 06-ai.md AI-410 and 07-modding-content.md MOD-610 had already specified that mechanism while AR-855(c) forbade it. AR-855(c) now adopts it, AR-857 records the transform id, version and enforcement mode, and the ABI-level counter is gone rather than demoted — 06-ai.md AI-420 admits only two interrupt mechanisms and it is not one of them. The residual open item is overhead, not mechanism: AI-410 and MOD-610 both flag instrumentation cost as unmeasured, and AI-400's budget defaults were written against an unmetered runtime. Open: what the transform costs on the shipped SDK toolchains, and whether any §10 budget must widen to absorb instrumented plugin execution in async server turns and in the AR-970 farm. This is 06-ai.md's and 07-modding-content.md's measurement to take; 03's exposure is confined to those budgets.
  4. Engine-version exposure (AR-070, AR-960(b)). The claim that the core is engine-independent rests on using only exactly-specified operations, and CI proves it across three engines and two Node majors. Open: does the product also need a pinned Node version for authoritative servers, and what is the policy when a browser ships an engine change that diverges — refuse the client, or accept a fork in the corpus? The mobile webview case is the sharp end — AR-870 now requires those engines to be gated and a failing one refused at startup — and what is open is which minimum Android System WebView version to declare.
  5. u64 representation at the boundaries (AR-150, AR-260). Seq, Seed, PRNG state and TurnHash are 64-bit. This revision fixes bigint for Seq, hi/lo pairs for PRNG state, and decimal strings in JSON projections. Open: whether one representation should be used uniformly for legibility, and what the JSON projection's contract is for tools that parse it with a naive JSON reader — a decimal string is safe but silently changes type if a tool round-trips it.
  6. Dependency policy (AR-010, AR-880). The core's dependency allowlist is currently "empty or explicitly justified", which is strict enough to be honest but not yet operational. Open: is a hard zero-dependency rule for packages/core achievable given the hash, compression, and ZIP work now written in-repo, and what is the review gate for adding one — since under AR-880 a transitive dependency change is a candidate rules-observable change.

Raised by this revision's reconciliation of §10 and §12:

  1. The two-configuration split in AR-760 is derived, not measured. AR-740, AR-760 and AR-770 previously stated three mutually unsatisfiable numbers for the same fixture (≤ 2 ms of fog inside a ≤ 1 ms order inside a ≤ 100 µs throughput budget). They are now consistent, but consistency was obtained by deriving the tighter figures from AR-770's throughput claim — ≤ 100 µs of simulation per order, of which ≤ 40 µs is visibility maintenance, with projection and serialization moved into a separate ≤ 1 ms interactive row and a new ≤ 60 ms simulation-only Reckoning row. Every one of those is an engineering estimate against a V8 profile nobody has run; 09-roadmap.md's Phase 1 re-baseline is where they meet a measurement. Open: if the measurement disagrees, which figure is the fixed point — AR-770's 10,000 orders/second, which drives CI and farm throughput, or AR-760's ≤ 1 ms interactive order, which is what a player feels? They cannot both be held if the simulation-only cost lands above 100 µs. Current lean: hold the interactive figure and restate throughput, because a slow farm costs machine time and a slow order costs the product.
  2. Does PluginRuntimeConfig belong in hashed setup at all (AR-857)? It is placed inside initial.bin's hashed setup so it cannot be edited away from a container, matching how AR-490 treats the AI Full Vision option. But unlike that option it changes nothing the rules compute, and hashing it means two otherwise identical games recorded under different instrumentation versions have different StateHash values — a difference with no rules meaning that a naive comparison would report as a divergence. The alternative is the manifest, covered by the AR-570 per-entry digest but outside StateHash. Open, and it interacts with AR-960(c)'s expected-hashes file: if the corpus is regenerated after an instrumentation bump, hashed placement forces a reviewed hash change under AR-960(e) for a change that is not rules-observable. Lean: keep it hashed and make AR-960(e)'s justification path carry the case, since the failure mode of the alternative — a metering record a container owner can quietly rewrite — is worse.

Raised by reconciling this document with the settled turn model and the withdrawal of the fidelity premise:

  1. The built Phase 0 code and this document disagree in four places, and one of the two must move each time. packages/core is green against the previous revision, and reconciling it is a code task with a decision in it, not a documentation task. The four are: the digest primitive (the code ships a self-documented FNV-1a placeholder where AR-130 names XXH3 and SHA-256, and exposes one hashCanonical where AR-130 names four distinct digests); the stream registry (the code derives mapgen, spawn, combat, naming, where AR-370 now names mapgen, spawn, victory, misc and retires combat and naming to positional addressing); coreHash (AR-135 requires it and nothing implements it, which matters because 11-combat.md CB-800 is written entirely against it); and the CI matrix (the workflow covers Node 22 on three desktop OSes and compares hashes across them — the hardest part — but not the browser engines, the adjacent Node major, the 1-vs-N worker legs, or the committed expected-hashes file that AR-960(c) requires and whose absence means a change that moves the hash identically everywhere passes). In every case this document is the one that should hold; the code was built against an earlier revision and each gap is contained. Sequence matters: coreHash and the stream registry MUST land before any combat or victory code, and the digest swap MUST land before any hash is written into a container anyone keeps.
  2. What is misc actually for, and should it exist at all (AR-370)? The registry now has three streams with named owners and one placeholder. A stream nobody draws from costs a (state, inc) pair in every save and one more thing to hash. Open: delete it and require a new subsystem to add a named stream with an owning requirement — which is the same review either way — or keep it as the escape hatch that stops a subsystem quietly drawing from mapgen. Lean: keep it for exactly one release cycle and delete it if nothing has claimed it, because the failure it prevents (a subsystem borrowing another's cursor) is worse than the cost it carries.
  3. Does a golden master of an observation stream belong in the repository (AR-930(a))? Recording per-player streams is the only mechanical way to catch a fog leak that is correct — a field that should not be there but is faithfully produced every time, which AR-950(c) cannot see because it is stable under the mutations that test applies. The cost is that a committed stream is a committed answer to "what could player 2 see on turn 40", and every rules change that legitimately alters observation churns it. Open: record streams for a small number of dedicated fog-corpus games rather than for the whole corpus? Lean: yes, three or four games chosen to exercise concealment (02-units-and-industry.md US-640), fidelity decay (01-game-rules.md GR-1260) and bombardment against an unobserved target (AR-470).
  4. Is seat-to-start-position assignment a draw at all (AR-370's spawn stream)? 01-game-rules.md GR-1570 requires the generator to place start cities and 02-units-and-industry.md to specify the starting units, but no requirement says who receives which position. Three answers are all defensible: a SetupConfig parameter (a ladder game wants seats assigned, not drawn), a draw from spawn, or a generator responsibility recorded in Map.Provenance. Until 01-game-rules.md settles it, the spawn stream exists so that the answer "it is drawn" does not require perturbing mapgen afterwards. If the answer turns out to be "it is a parameter", spawn should be deleted with misc under question 24.
  5. Who owns the seed-secrecy rule of AR-460 at the transport? This document can require that the seed never appear in a PlayerView, and it does. It cannot enforce what a host sends: a self-hosted server, a relay-only game where the designated host client holds the full container (AR-830), and a hot-seat game that later switches to online play (AR-630) each put the seed in a place fog enforcement does not reach. 05-multiplayer.md owns authority modes and MUST state which of them can honestly promise fog enforcement now that holding the seed is equivalent to predicting every combat. Lean: relay-only games cannot promise it and should say so at setup rather than implying a guarantee they cannot keep.