07-modding-content.md · 139 requirements

07 — Modding & Content Pipeline

This document specifies Every Last City's content and modding system: the mod package format and manifest; semantic versioning and dependency resolution; the content-identity hashes that make a multiplayer game verifiable; the data formats for unit sets, terrain sets, maps, scenarios, rule presets, command libraries and victory Terms; asset packs and localization; the sandboxed WASM plugin ABI for AI players and map generators; the map/scenario editor; and the in-game workshop that distributes all of it. Its organising claim is a boundary: content that is data is safe to load from anywhere and cannot desync a game, and content that is code runs behind an explicit trust decision in a sandbox. Everything in this document is an argument about which side of that line a capability belongs on, and about making the data side wide enough that almost nothing needs the other one.

Status: Draft v0.2 · Owner: unassigned · Depends on: docs/design/00-direction.md, docs/design/01-decision-turn-model.md, 01-game-rules.md (map, terrain, Installations, setup parameters, generation invariants), 02-units-and-industry.md (the unit definition schema, the capability-flag vocabulary, the Industry ladder, unit-set validation), 03-architecture.md (determinism, canonical hashing, the WASM plugin host, PRNG streams), 04-ui-ux.md (presentation of the editors and the setup screen), 05-multiplayer.md (content pinning and distribution), 06-ai.md (the AI plugin contract), 08-services-platform.md (workshop hosting and quotas), 10-turn-model.md (orders as data, the Orders phase), 11-combat.md (the win predictor the editor displays), 12-economy.md (city classes and traits), 13-command.md (Postures, Sanctions, Doctrines), 14-victory.md (the Track catalogue and the Terms)

Replacement notice, binding. This document previously specified a content format transcribed from another game's unit database, and an importer that read that game's data files. Both are deleted in full under docs/design/00-direction.md §2. Section 10 (EDCE migration and importers) and its requirements MOD-920, MOD-930, MOD-940, MOD-950, MOD-960, MOD-970 and MOD-980 are retired. Those identifiers MUST NOT be reused for new requirements, because a reused number silently redirects an existing citation rather than breaking it. 06-ai.md AI-265 and its §5 float-discipline paragraph once cited MOD-980; both have been re-resolved — to MOD-670(a) and MOD-600 respectively — and no citation of a retired identifier remains anywhere in the set. MOD-1290 records what was checked.

The prior draft also carried a legacyKey field, an x-edce extension block, a per-target Combat Mod matrix, a per-unit terrain-cost map, four named built-in unit sets, and a "Classic mode" that pinned certified content hashes. None of those correspond to anything in the current specification set and all are gone. Where a requirement number survives, its content has been rewritten in place against 01-game-rules.md, 02-units-and-industry.md, 10-turn-model.md, 13-command.md and 14-victory.md.


1. Content model and mod packages

MOD-010 All game content — unit sets, terrain sets, rule presets, asset packs (graphics, audio, name lists), localization packs, maps, scenarios, command libraries, victory Terms presets, AI players and map generators — MUST be delivered as mod packages in a single documented format. There MUST be no "magic directory" installation mechanism, no loose-file drop location, and no content path that bypasses validation.

Rationale: one pipeline is what buys validation, discoverability, hot-loading, an uninstall that actually removes something, and — the load-bearing one — a content hash a lobby can compare. Loose files give none of those, and every failure they produce arrives mid-game rather than at install.

no-test-needed: the subject of this row — all content delivered as validated mod packages — does not exist in this tree; nothing under packages/client/src or packages/core/src opens a .elcmod archive, and there is no magic directory to forbid. What stands is the built-in half without its envelope: the shipped unit set loads from source (packages/core/src/units/roster.ts reads standard.json through loadUnitSet) and terrain from BASE_TERRAINS in world/terrain.ts. It becomes testable when intake lands per MOD-020/MOD-085: every content type then flows through one validated archive path, and a loose-file route is the violation to pin.

MOD-020 A mod package MUST be a ZIP archive with the extension .elcmod, containing a manifest.json at the archive root plus content files in documented subpaths. All package formats, JSON schemas, and the plugin ABI MUST be published in the project's public documentation; a package MUST be creatable and readable with standard tools outside the game. Archive entries MUST use only the store and deflate compression methods and MUST NOT be encrypted; Zip64 extensions MUST NOT be required by a conforming package.

Rationale: a browser client reads the archive in JavaScript over the platform's own DecompressionStream. Every method beyond store/deflate obliges the client to ship an additional decoder for content it has not yet verified, which is both weight and attack surface; the MOD-885 caps keep conforming packages well inside the Zip64 threshold.

no-test-needed: there is no archive reader in this tree for the format obligations to bind — nothing reads ZIP entries, store or deflate alike, outside the build tooling that ships the client itself. The published-schemas half likewise has nothing published yet (the unit schema exists as TypeScript types at packages/core/src/units/schema.ts, not as a document an external tool could consume). It becomes testable with the intake path: a conforming .elcmod must be creatable and readable by standard tools outside the game, and any entry using another compression method or encryption is refused before materialization.

MOD-030 manifest.json MUST contain at minimum the following fields:

Field Type Requirement
manifestVersion integer Format version of the manifest itself; readers MUST reject unknown major versions with a clear message.
id string Globally unique, stable package identity: reverse-DNS style (com.author.packname) or UUID. MUST never change across versions.
name string (localizable key permitted) Display name.
version string Semantic version (MAJOR.MINOR.PATCH per SemVer 2.0.0).
authors array of strings Author display names.
license string SPDX identifier or custom with a licenseFile path.
description string Short description (localizable).
engineApi string SemVer range of the content/plugin API the package targets; the client MUST refuse to load packages outside its supported range with a human-readable error.
content object Declares the content items in the package by type (see MOD-040).
dependencies array Each entry {id, version} with version a SemVer range (see MOD-050).
tags array of strings Free-form discovery tags.
icon string path Optional PNG/SVG icon.

no-test-needed: no manifest exists anywhere to enumerate — built-in content carries none at all (standard.json loads straight from the source tree with its fields known to the loader), so the required-field table has no reader and no writer. It becomes testable when packages land: a manifest missing any field named in this row fails as an itemized error under MOD-080, naming file, field and reason rather than crashing.

MOD-040 The content object MUST enumerate the package's items under typed keys, each item naming its root file inside the archive. The complete set of content types, the section that owns each format, and its classification under MOD-150, MUST be exactly:

Key Format owned by Classification
unitSets §3 sim-affecting
terrainSets §4 sim-affecting
maps, scenarios §9 sim-affecting
rulePresets §11 sim-affecting
plugins (with kind ∈ ai | worldgen) §8 sim-affecting
commandLibraries §11 sim-affecting
victoryTerms §11 sim-affecting
assetPacks §6 presentation-only
localization §7 presentation-only
nameLists §6 presentation-only
encyclopedias §3 (MOD-295) presentation-only

A package MAY contain multiple items and multiple types — a unit set plus its asset pack plus a demo scenario in one package is the expected shape. A content type not on this list MUST be rejected at validation rather than ignored.

no-test-needed: there are no typed content keys to enumerate — nothing supplies assets or data by key from a package, since no packages exist; the sim-affecting versus presentation-only classifier this table feeds therefore has no subject. Its nearest live analog already holds at the engine: unit-set definitions enter the canonical game hash (US-2090's digest in packages/core/src/units/digest.ts) while display strings never do. It becomes testable when content arrives by key: every type lands in exactly one class, and MOD-150's two-load CI check pins that presentation-only stays out of the sim hash.

MOD-050 Dependency resolution MUST use SemVer ranges. The client MUST refuse to enable a package whose dependencies are missing or version-incompatible, listing exactly what is missing and offering one-click resolution via the workshop when online (MOD-860). Circular dependencies MUST be rejected at install time.

no-test-needed: no dependency graph exists to resolve — nothing in this tree declares package dependencies (the setup screen's unit-set selection is a plain string field, draft.content.unitSet at packages/client/src/setup/model.ts), so SemVer ranges and refusal of unresolvable ones have no subject. It becomes testable when packages land: an enabled set whose required dependency is missing or out of range must be refused with the offending pair named.

MOD-060 The shipped unit set (02-units-and-industry.md §8), the shipped terrain set (01-game-rules.md §3), the stock rule presets, the stock victory Terms presets (14-victory.md §12.5), the stock asset packs, the stock AI plugins and the stock map generators MUST ship as ordinary read-only mod packages conforming to this specification — data, not code paths. Built-in packages are certified: their content hashes (MOD-140) are pinned per client release.

Rationale: this is the only honest way to prove the formats are sufficient. If the game's own content needed one private code path, every modder's content would need it and would not have it — which is the same argument 02-units-and-industry.md US-2070 makes about the flag vocabulary, and it is right for the same reason.

no-test-needed: the built-ins exist but not as packages — standard.json and BASE_TERRAINS are source-tree data with no .elcmod envelope, so shipping them as certified read-only conforming packages has nothing to build on yet. The identity half of the obligation already stands in miniature: US-2090's digest folds a content hash of the standard set's definitions into every game hash (packages/core/src/units/digest.ts), which is exactly what certification needs per release. It becomes testable with packaging: each built-in ships as an ordinary conforming package whose hash is pinned, and any private code path it would have needed is the violation to pin.

MOD-070 The client MUST provide an in-game Mod Manager that can browse installed packages, install from file or workshop, enable/disable per package, update, and uninstall — without restarting the application. Per-game content selection happens at game setup; the Mod Manager governs what is available to select.

no-test-needed: no Mod Manager exists — nothing under packages/client/src browses, installs, enables or uninstalls packages (the verified absence behind this note), so per-package toggles without restart and the manager/setup split of responsibilities have no surface. Game setup does already carry a content selection field (draft.content.unitSet at packages/client/src/setup/model.ts) that this row's second sentence would govern. It becomes testable when the manager lands: install-from-file, enable/disable and uninstall each act on live state with no application restart.

MOD-080 Package installation MUST validate the package against the published schemas before activation and MUST report failures as human-readable, itemized errors identifying file, field, and reason. A malformed package MUST never crash the client or abort application startup. Validation MUST report every failure in one pass rather than stopping at the first, matching the obligation 02-units-and-industry.md US-800 places on the unit-set validator.

no-test-needed: there is no installation path to validate — nothing reads a package before activation, so the one-pass itemized failure reporting has no subject. Its engine half already exists for the row to wrap: validateUnitSet at packages/core/src/units/validate.ts implements US-710 through US-800 and reports every failure in a single pass with the core's own wording. It becomes testable when install lands: a malformed .elcmod must never crash the client or abort startup, and each named error identifies file, field and reason.

MOD-085 Archive intake limits. Reading a .elcmod MUST be bounded before any content is materialized. The reader MUST run off the main thread — a Web Worker in a browser client, a worker_thread in Node — so that a hostile or merely enormous archive cannot freeze the UI or stall a running game, and MUST enforce a total-decompressed ceiling, a per-entry decompressed ceiling, an entry-count ceiling, and a decompression-ratio guard, defaulting to the corresponding MOD-885 figures and configurable downward per platform. Exceeding any ceiling MUST abort with an itemized error (MOD-080), never with an out-of-memory failure, and MUST leave no partially installed package. Entry names MUST be relative POSIX paths whose segments match MOD-120's character rules; absolute paths, drive letters, .. segments, symlinks, and entries colliding after case folding MUST be rejected.

Rationale: MOD-885's caps are a publication control and explicitly do not apply to sideloading (MOD-900) — which is precisely the path an attacker controls, and the one a browser client exposes to any file a user is talked into dropping on it. On a desktop build an over-large extraction fails a disk write; in a browser tab it kills the tab and takes the running game with it.

no-test-needed: there is no archive intake to bound — nothing reads ZIP bytes before materializing content (there is no reader at all), so off-main-thread execution, decompression ceilings and the ratio guard have no subject; entry-name rules await the validation surface of MOD-120. It becomes testable with the intake path under MOD-080: an over-ceiling archive aborts with an itemized error naming the limit, leaves no partial package behind, and a zip-bomb-shaped ratio is refused before any bytes are trusted.

MOD-090 Content hot-loading: data and asset changes to an enabled package MUST take effect without an application restart, and in a browser client without a page reload. Data that affects a running simulation — anything MOD-040 classifies sim-affecting — MUST NOT change mid-game; such changes take effect for the next game created. Presentation-only changes MAY apply immediately to a running game.

no-test-needed: there are no enabled packages whose changes could apply — hot-loading has nothing to load (see MOD-070's own row for the manager's absence). The sim-affecting half's promise already holds at a deeper seam than this row asks for: unit-set definitions enter the game hash at turn zero via US-2090's digest (packages/core/src/units/digest.ts), so a changed set cannot ride along in a running game without the two builds desyncing immediately. It becomes testable when loading lands: data changes take effect next-game, presentation-only changes may apply live.

MOD-095 Untrusted content isolation. A mod package MUST NOT be able to introduce executable web content into the client. Package files MUST NOT be loaded as HTML documents, scripts, stylesheets, or module URLs; manifest.json, content JSON, and localization files are parsed as data only. SVG assets (MOD-410) MUST be sanitized at install — scripting, external references, and foreignObject stripped — and rasterized through a non-executing path (an image decode from a blob, never markup injected into the document). The client MUST ship a Content-Security-Policy that denies unsafe-eval and confines script-src to the application's own bundle, and MUST render every content-supplied string as text rather than markup (MOD-520). WebAssembly under §8 is the only executable content a package may carry, and it runs under MOD-590's sandbox with none of the ambient authority the page itself holds.

Rationale: this is the largest attack surface a web client introduces. In a DOM application, content that reaches an innerHTML or a script URL runs with the client's own origin privileges — the player's session, their saved games, their account token. A rule is required; good intentions in an asset loader are not.

no-test-needed: no package can introduce executable web content because no package file is loaded anywhere — the only code in this tree's client runs from its own bundle. §8's plugin path, which this row carves out as the one permitted exception, does not exist either (no host module under any package; the WASM mentions in server source are docblocks naming the seam it will fill). The SVG sanitization and CSP clauses therefore have no firing condition today. It becomes testable when packages and plugins land: every content string renders as text, an unsanitized SVG is refused at install, and a plugin's only reach is MOD-590's vetted imports.

MOD-100 When multiple enabled packages supply the same asset key (MOD-460) or localization key, the client MUST resolve the conflict by a user-orderable package priority list, deterministic and persisted per profile. Sim-affecting content MUST NOT silently override by priority: each is selected explicitly by identity at game setup.

no-test-needed: there are no asset-key or localization-key conflicts to resolve — nothing supplies two keys (there are no packages), so the user-orderable priority list has no subject; per-profile persistence awaits profiles themselves, which 08-services-platform.md's SVC-100 row records as equally absent. It becomes testable when packs land: equal keys from enabled packages resolve by a persisted deterministic order, while sim-affecting content never overrides silently and is chosen explicitly at setup.

MOD-110 The content location MUST be surfaced honestly, per platform. On builds that have a filesystem (Tauri desktop and mobile, self-hosted servers, the CLI) the user content directory MUST be a normal in-game setting, and the Mod Manager MUST display the resolved content locations and offer "open in file manager". A browser client has no such directory and MUST NOT pretend otherwise: there the Mod Manager MUST show the origin-private store of MOD-115 instead — its usage against the browser's quota, whether persistent storage was granted, a control to export any installed package back out as a .elcmod file (MOD-890), and a control to clear the store. Display and graphics settings belong to the normal settings UI (04-ui-ux.md).

no-test-needed: there is no content location to surface — no user content directory exists on any platform in this tree (the browser store of MOD-115 holds nothing because nothing is installed), so neither the desktop open-in-file-manager control nor the browser's honest display has a subject. It becomes testable when stores land: filesystem builds show and open their real directory, and the browser build shows its origin-private store with quota usage — never a fake path.

MOD-115 Where installed packages live. Installed packages MUST be held in a store the client owns, and the Mod Manager MUST show which store is in use:

Platform Store
Browser Origin-private storage — the Origin Private File System where available, otherwise IndexedDB — holding the original .elcmod bytes plus the derived bundle of MOD-505. There is no user-visible directory and no user-editable file.
Tauri desktop/mobile, self-hosted server, CLI An ordinary content directory on the filesystem, located per MOD-110.

Origin-private storage is evictable, so a browser client MUST request persistent storage, MUST treat eviction as an expected condition rather than corruption, and MUST keep the installed-package record — (id, version, packageHash, simHash, source) — in the profile rather than in the evicted store, so that a missing package can be re-fetched from its origin (MOD-125) or re-requested from the user (MOD-900) without losing what was installed. Quota exhaustion MUST surface as an itemized, actionable error naming what could not be stored (MOD-080). A package that is evicted while a game using it is in progress MUST be re-fetched, or the game MUST stop with a clear error naming the package — it MUST NOT continue with substituted or absent content.

no-test-needed: no package store exists on any platform to hold or evict — the client persists settings through its service worker and saves locally, but there is no origin-private content area, no filesystem content directory, and no (id, version, packageHash, simHash, source) record anywhere. It becomes testable when stores land: eviction of a browser-held package re-fetches from its origin or stops the game with the named package — it never continues on substituted content — while the installed record survives in the profile.

MOD-120 All content keys and package ids MUST be compared case-sensitively by the engine, and package validation MUST reject any two keys within one scope that differ only by letter case. Identifiers MUST match the pattern that owns them:

Identifier Pattern Owner
Unit definition id, setId ^[a-z][a-z0-9_]{1,31}$ 02-units-and-industry.md US-070
Terrain type id, terrain set id ^[a-z][a-z0-9_]{1,31}$ §4, by analogy with US-070
Package id reverse-DNS or UUID MOD-030
Asset key, localization key, preset key [A-Za-z0-9_.-]{1,64} this document

Rationale: case-insensitive collision rejection removes a class of bug that only appears on someone else's filesystem. Adopting US-070's pattern verbatim for unit ids, rather than declaring a looser one here, means a set that validates against the published JSON Schema also passes the core's own US-720 check — two validators that can disagree is the defect this table exists to prevent.

no-test-needed: the pattern half already stands at the engine for unit ids (US-070's rule enforced by the core validator), but this row's operative subject — cross-key case-collision rejection across package scopes — has no subject yet, since packages supply none of the keys in its table. It becomes testable when package validation lands: two keys within one scope differing only by letter case are both rejected, with each pattern from the table checked at install.

MOD-125 Delivery to a browser client. A browser client fetches every package over HTTP(S) under the origin policy and can hold none of it on a filesystem, so:

no-test-needed: there is no content origin to deliver from — the client's outbound surface is its game and lobby sockets plus same-origin assets; nothing fetches package bytes over HTTP(S) because no packages exist, so CORS headers, immutable cache addressing, secure-context checks and range-request progress have nothing to be sent for or read. It becomes testable when a content origin lands (the workshop of §10 or MOD-870's self-hosted serving): an opaque cross-origin response is refused before any hash check because its bytes could not be verified, and the client reports the missing header by name.


2. Content identity and the compatibility hash

MOD-130 Every package version MUST be identified by the triple (id, version, contentHash). Published versions are immutable: re-publishing changed content under the same version MUST be rejected by the workshop (MOD-840) and detected locally by hash mismatch.

no-test-needed: no published versions exist to make immutable — nothing under packages/ or site/ serves a versioned package, so re-publish rejection at the workshop and local detection of changed bytes under an old version have no subject. It becomes testable when publishing lands (MOD-840): the same (id, version) with different content is refused server-side, and a locally stored copy whose hash no longer matches its record is detected before use.

MOD-140 contentHash MUST be a SHA-256 digest computed over a canonical serialization of the package's content: JSON documents canonicalized per the rules in 03-architecture.md (sorted keys, normalized numbers, UTF-8), binary assets hashed raw, combined as a Merkle tree over sorted paths. The hash MUST be reproducible by third-party tools from the documented algorithm. Two hashes MUST be computed and recorded: the package hash (everything) and the sim hash (sim-affecting content only, per MOD-150).

Canonicalization MUST operate on the document's own textual form and on exact integer values; a parse-and-re-serialize round trip through the host language's native number type MUST NOT be used to produce it. TypeScript has exactly one numeric type (IEEE-754 float64), so that round trip silently reformats numeric literals and cannot represent integers beyond 2^53 exactly — two clients would then compute different hashes for identical bytes, or identical hashes for different content. The canonical number grammar MUST be published, and a package containing a number that grammar cannot represent exactly MUST fail validation (MOD-080) rather than be hashed approximately.

Rationale: the sim-affecting content this document carries is integer throughout — 02's schema (US-030), 01's terrain attributes (GR-350), 12's per-mille traits, 14's Track parameters (VC-110) — so a canonical number grammar that admits only exact integers within a stated range is sufficient and is far cheaper to specify than a general one. The failure this guards against is silent, intermittent, and only ever visible as a multiplayer content mismatch (MOD-160) that no player can explain.

no-test-needed: the package-level split of contentHash from sim hash does not exist — there are no packages to digest. The nearest live analog stands at the engine: US-2090's unit-set identity already folds a content hash of the set's definitions into the canonical game hash (packages/core/src/units/digest.ts), which is the shape this row generalizes from one built-in document to every package. It becomes testable when packages land: digests over canonical serialization, with the sim hash covering exactly the files MOD-040 classifies sim-affecting and nothing else.

MOD-145 Computing and re-checking hashes without a local directory. MOD-140's digests MUST be computed once, over the bytes as they arrive, and thereafter carried by the installed-package record (MOD-115) — never by scanning a content directory, because a browser client has no directory to scan. The normative sequence:

  1. On install, from any source — a workshop or server fetch (MOD-125, MOD-870) or a file the user handed the client (MOD-900) — the client MUST stream the archive, compute the package hash and the sim hash per MOD-140 using the platform's subtle-crypto SHA-256 (Node: the standard crypto module), and record (id, version, packageHash, simHash, source, installedAt).
  2. Against an advertisement. Where the source advertised a hash — a workshop listing, or a lobby under MOD-160 — the computed value MUST be compared before activation, and a mismatch MUST abort the install showing both values. Content MUST NOT be trusted because of where it came from; TLS authenticates the origin, not the package.
  3. On later use, the check is against the stored record, not a re-scan. A browser client's package store is not user-editable, so a recorded hash stays valid as long as the stored bytes are the bytes that were hashed. The client MUST re-hash the stored bytes on demand from the Mod Manager, whenever a lobby check fails, and after any storage error, and SHOULD re-hash lazily in the background.
  4. On platforms with a real filesystem the content directory is user-editable, so the client MUST re-hash a package whenever its file has changed since install — size and mtime as a cheap gate, a full re-hash on any difference — and MUST re-hash unconditionally before a multiplayer launch (MOD-160).
  5. Developer-mode unpacked packages (MOD-670) have no stable hash by construction. They MUST be marked uncertified, MUST cause the game to be labelled Modded (MOD-170), and MUST be refused by multiplayer verification (MOD-160) rather than hashed opportunistically.

Rationale: hashing at the moment of receipt makes the check a property of the transfer rather than of a directory the user may have edited between games. The weaker case is the desktop build, which is why item 4 exists; what would otherwise be one uniform rule is two, and the desktop path is the one that can go stale.

no-test-needed: there are no install-time hashes to compute or re-check — the intake path does not exist (see MOD-085's own row) and no stored record exists to check against (MOD-115), so all five steps of this sequence have no subject. It becomes testable with installation: hash at receipt, compare before activation showing both values on mismatch, re-hash from the Mod Manager on demand, and — item 4 as the weaker half that deserves its own pin first — a desktop content directory re-hashed whenever files change since install.

MOD-150 Every content type MUST be classified as sim-affecting or presentation-only, per the table in MOD-040. Presentation-only content MUST NOT be able to alter any value the simulation computes, MUST NOT enter the sim hash, and MUST NOT be required to match between players in a multiplayer game.

The classification is not a convention, it is a testable property: a CI check MUST load a reference game twice, once with every presentation-only package in the catalogue enabled and once with none, and MUST assert that the canonical state hash (03-architecture.md) after a fixed replay is identical.

Rationale: "presentation-only" is the promise that makes localization packs, art packs and name lists free to share and free to mismatch across a lobby. A promise this load-bearing needs a test, because the way it breaks is that some well-meaning code reads a display string to sort a list — which 04-ui-ux.md may legitimately do for display, and which MOD-545 forbids reaching state.

no-test-needed: no presentation-only packages exist for the classification to keep out of the sim hash (see MOD-040's own row), so the promise and its CI check have no subject today. The check's shape already exists in miniature elsewhere: deterministic corpus replay re-runs recorded games against golden hashes under scripts/corpus-replay.mjs, which is precisely this row's load-twice-and-compare-canonical-hashes pattern applied to engine changes rather than content. It becomes testable when presentation-only packages land: a reference game run with all of them enabled versus none must hash identically after a fixed replay.

MOD-160 Multiplayer content verification: a lobby MUST advertise the (id, version, sim hash) of every sim-affecting package the game uses; clients MUST verify local sim hashes match before the game starts, and mismatches MUST block launch with a diff of what differs. Missing packages are fetched per MOD-870. This is the content half of the pinning 05-multiplayer.md MP-110 requires and the distribution MP-120 specifies; enforcement mechanics are owned there.

no-test-needed: the per-package (id, version, sim hash) advertisement does not exist because there are no packages — but its enforcement seam is real and pinned by its own requirements: joinVerdict in packages/core/src/net/pin.ts implements MP-110/MP-120's absent-fetch versus mismatch-block split, and lobby state already carries a contentHashes array (packages/server/src/lobby.ts). What is missing is the sim-affecting package set to advertise. It becomes testable when packages land: every advertised triple must be verified locally before launch with a diff on mismatch, and a missing package fetched per MOD-870 rather than guessed.

MOD-170 A game MUST be labelled Certified when every sim-affecting package it uses is a certified built-in (MOD-060) at its pinned hash, and Modded otherwise. The label MUST appear in setup, in-game, in saves and replays, and in multiplayer listings. Consequences for ladders and matchmaking are specified in 05-multiplayer.md and 08-services-platform.md.

Rationale: the previous draft made this a "Classic mode" gate on reproducing another game exactly. There is no such mode and no such obligation. What survives is the part that was always doing real work: a competitive ladder needs to know whether two results were produced under the same rules, and one boolean derived from hashes the client already computes answers that without asking anyone to declare anything. Modded is a label, not a penalty — 08-services-platform.md decides what, if anything, it costs.

no-test-needed: there is no label surface — with only built-in content playable (a custom set can load and validate but never plays, recorded in packages/core/src/units/digest.ts's own docblock), every game today is Certified by default and nothing displays the label anywhere it would need to appear. It becomes testable when custom sets become playable: a boolean derived from pinned hashes must show as Certified or Modded in setup, in-game, saves, replays and listings — with the label carrying no penalty this row does not grant.


3. Unit sets as content

02-units-and-industry.md owns what a unit is. This section owns how that is written down, what the editor shows, and what a loader must refuse. Where the two could disagree, 02 wins and this document is the defect.

MOD-180 A unit set MUST be a single JSON document (unitset.json) validating against a published JSON Schema, and that schema MUST be an exact expression of the unit definition schema of 02-units-and-industry.md §2 and the capability-flag vocabulary of its §3. The document MUST contain a set identifier, a schema version, and an array of unit definitions, per US-700. The core's own validator (US-710 through US-800) is authoritative; the published JSON Schema exists so that authors, generators and the workshop's pre-publication scan (MOD-880) can reject a bad set without loading the core, and MUST NOT accept anything the core rejects.

no-test-needed: the published JSON Schema does not exist — what this row asks for is currently TypeScript types (packages/core/src/units/schema.ts), which authors and external tools cannot consume, so exact-expression-of-US and the workshop pre-publication scan have no subject. The validator it must agree with stands fully: validateUnitSet at packages/core/src/units/validate.ts implements US-710 through US-800 over a real shipped document (standard.json). It becomes testable when the schema publishes: it must reject exactly what the core rejects — an accepted-but-invalid or rejected-but-valid case in either direction is the pin.

MOD-185 The closed-vocabulary rule. Several vocabularies this document serializes are closed by the specification that owns them, and no data package may extend one. A loader MUST reject a document that names a value outside the owning enumeration, rather than ignoring it. The complete list, with its owner:

Vocabulary Size Closed by
Unit class 18 US-090
Capability flags 18 US-470
domain, terrainClass 3, 5 US-120
Tile Installations; edge features 3; 3 GR-420, GR-450
Movement classes in the terrain cost table 5 GR-290
City traits 9 EC-460
Postures; Sanctions 6 + Idle; 4 CM-330, CM-570
Stance 4 TM-630
Victory Tracks 14 VC-880

Rationale: this table is the single most useful thing this document can state, because the honest answer to "what can a mod change?" is otherwise discovered one rejection at a time. Each of these vocabularies is closed for a reason its owner argues — a nineteenth flag is an untested branch (US-480), a seventh Posture is a rules change requiring a commandLogicVersion bump (CM-330), a fifteenth Track breaks every readout and estimator in 14-victory.md (VC-3200). The modding promise is not that everything is editable; it is that everything editable is data, and that the line is published rather than found. Where a genuinely new behaviour is wanted, §8's plugin ABI is the escape hatch and US-2060 makes it the only one.

no-test-needed: the loader that would enforce rejection of out-of-vocabulary values does not exist for package documents — no data package can be loaded today (see MOD-070's own row), so the closed-vocabulary table has no reader. What stands is every vocabulary it lists, each enforced by its owner at engine level: UNIT_CLASSES and CAPABILITY_FLAGS as const tuples in packages/core/src/units/schema.ts, movement classes in world/terrain.ts, the rest cited to their owning rows. It becomes testable when a loader lands: a document naming a nineteenth flag or a seventh Posture is refused with the value named, never ignored.

MOD-190 Set-level metadata MUST include: setId (matching MOD-120 and US-070), a stable guid (UUID) that survives renaming, localizable name and description, schemaVersion, an auto-incremented revision bumped on each editor save, and an optional suggestedRulePreset naming a rule preset (§11) by package id and key. Selecting the set at game setup MUST apply the suggested preset as defaults which the setup player may override.

no-test-needed: no set-level metadata exists as data — standard.json's records are consumed by roster.ts directly and carry none of this row's envelope (guid, revision and suggestedRulePreset have no reader), so applying a suggested preset at setup has no subject. It becomes testable with packages and the editor: every field here round-trips through an authored set, and selecting one applies its named preset as overridable defaults.

MOD-200 A unit set MUST NOT contain any reserved, undeletable, or specially-named unit record, and the format MUST NOT provide a set-level map binding a role to a unit id. Every behavioural role — capture, garrison, administration, supply projection, construction — MUST be selected by the engine from the class enumeration (US-090) and the capability flags (US-470) of §3, never by identity.

Rationale: this reverses the prior draft, which required five named records and a roles object mapping each to an id. US-060 forbids the core branching on a unit's id at all and requires a CI lint to prove it, so a role map is a mechanism the engine is not permitted to consume. It is also worse design on its own terms: with roles bound to flags, a set that ships three different administrator units works, and a set that ships none simply has no Integration path — which is a legitimate thing to build and which the old scheme made a validation error. The validation this removes is not lost; US-750's reachability check and MOD-350's warnings cover the real hazard, which is a set nobody can play rather than a set missing a magic key.

no-test-needed: there is nothing to keep out — the shipped set contains no reserved or specially-named records (the core branches on class and flags alone per US-060's own lint) and no role map exists in any document. The row stands as a format prohibition that holds by absence; its testable moment arrives with an authoring surface: a unitset.json naming a magic record or carrying a set-level role binding must be refused, because the engine is not permitted to read it.

MOD-210 Per-unit identity fields MUST be exactly the five of US-070 and no others:

Field Role
id Stable unique unit-type identifier within the set. Every cross-reference in the package resolves by id: sprites.json entries (MOD-430), name pools (MOD-480), encyclopedia entries (MOD-295), and scenario placed units (MOD-770). MUST NOT change once shipped (US-080).
setId The owning set. MUST equal the document's set identifier.
name Display name, localizable, 1–32 characters. Renaming a unit MUST change only this.
role The unit's one-sentence role, 1–160 characters, localizable (US-1620).
class One of the eighteen values of US-090.

There MUST be no second identifier, no short-code, and no compatibility alias. Cross-package references MUST be packageId:setId:unitId written in full.

Rationale: the prior draft carried a two-letter compatibility key alongside id and used it as the asset-lookup key, so a renamed unit kept its art and a set with a full two-letter namespace could not add a unit. Both problems were self-inflicted by an interoperability obligation that no longer exists. One identifier, used everywhere, is the whole of the design now.

no-test-needed: no external document supplies identity fields — standard.json's records are consumed by roster.ts with exactly the shape US-070 declares (schema.ts types them), so the five-field closure and full packageId:setId:unitId cross-references have no writer to constrain. It becomes testable when authored sets land: a record carrying a sixth field, a short-code or an alias is refused by MOD-180's schema agreeing with US-720, and every cross-package reference resolves by id in full.

MOD-220 class MUST be serialized as the lowercase enum string of US-090. The schema MUST NOT derive, infer, or accept a synonym for it, and MUST NOT carry any boolean subclass marker alongside it: every consumer that needs a grouping — 13-command.md's Requisitions (CM-1240) and Doctrine defaults (CM-790), 12-economy.md's class-scoped trait discounts — reads class per US-100.

no-test-needed: class serialization already matches this row for the shipped set — UNIT_CLASSES in packages/core/src/units/schema.ts are lowercase enum strings per US-090, and validation refuses anything else — but no published schema or authored document exists for the MUST-NOT-infer half to bind. It becomes testable when authoring lands: a mixed-case or inferred class value is rejected with the field named, never silently normalized.

MOD-225 Field-name conformance (normative). The unitset.json schema MUST use exactly the field names of 02-units-and-industry.md §2 — US-070, US-120, US-180, US-230, US-300, US-350 and US-380 — and the flag names of its §3. Where this document and 02 could differ in spelling, the 02 name wins and this document is corrected rather than reconciled.

The published JSON Schema MUST reject unknown top-level unit fields and unknown set-level fields, with no carve-out of any kind. There is no extension block, no x- prefix convention, and no opaque object a loader is permitted to accept and ignore.

Rationale: the prior draft carried exactly one carve-out, an x-edce block that existed so an importer's residue would validate against the schema it had to validate against. The importer is deleted, so the carve-out has no purpose — and it was never free: open question 17 of the prior draft recorded that the residue entered the sim hash and therefore changed a set's multiplayer identity for data the engine never read. Closing the schema completely deletes that question rather than answering it. US-720 gives the affirmative reason as well: silently ignoring an unknown field is how an author spends an afternoon wondering why stealth: true does nothing.

no-test-needed: exact field-name conformance holds for the one set that exists (standard.json's records match schema.ts to the field) — but there is no authored document yet whose names this row would police, and the published JSON Schema carrying the normative list does not exist (see MOD-180's own row). It becomes testable when authoring lands: a unitset.json with one renamed or added top-level field fails validation by name — US-720's unknown-field rejection applied to an external document for the first time.

MOD-230 The per-unit stat block MUST serialize exactly the fields of US-120, US-180, US-230 and US-300, as integers, within the ranges those requirements declare. The complete list, grouped as the editor presents it:

Group Fields
Movement and vision (US-120) domain, terrainClass, draft, move, vision, visionAir, endurance, zoc, mayOccupyCity
Cost (US-180) worksCost, manpowerCost, costEscalationPerOwnedPerMille
Combat (US-230) hits, atkLand, atkSea, atkAir, def, flak, strikeRun, initiativeBase
Transport (US-300) slots, capacity, carryDomains, carryMaxSlots

Every value MUST be a whole number written without exponent, sign, or fractional part, and a value outside the declared range MUST be a blocking validation error naming the field, the value and the range. There MUST be no fixed-point or floating-point field anywhere in the format; where a ratio is needed the field is per-mille per US-030 and its name MUST end PerMille.

no-test-needed: the stat-block serialization already holds exactly as specified for the shipped set — schema.ts types each group (movement and vision per US-120, cost per US-180, combat's eight integers per US-230, transport per US-300) and validateUnitSet enforces whole-number values in range. What is absent is any authored document: standard.json is the only instance, written to its own types by hand. It becomes testable when authoring lands: an exponent, sign or fractional part anywhere in a stat block is a blocking error naming field, value and range.

MOD-235 The Industry ladder as content. The ladder is content in exactly one respect and the format MUST expose exactly that one: which units each Industry level unlocks is expressed entirely by the per-unit buildRequirements.minIndustry value (US-350), so a custom set redistributes its own roster across the four levels freely and needs no ladder document at all.

The ladder's other properties are rules, not content. The number of levels, their range [1, 4] (US-810), their Works multipliers and their upgrade costs (US-820) MUST NOT be fields of a unit set. A rule preset (§11) MAY carry an override for the multipliers and upgrade costs only where 02-units-and-industry.md declares a legal range for them; until it does, the format MUST NOT expose them at all and a preset naming them MUST be rejected. MOD-1210 records the request.

Rationale: this is the escalating-complexity curve the direction brief asks for, and putting the whole of it in one integer per unit is the reason it is moddable at all. A teaching set that puts everything at Industry 1, a historical set that gates armour behind Workshop, and a naval set whose Arsenal unlocks nothing but hulls are all one column of a spreadsheet apart. The multipliers are a different kind of thing: US-880's flat upgrade costs and US-820's multiplicative output are load- bearing for the industrial geography the whole design argues for, and 02's own open question 3 says that decision is the one most likely to be reversed. Exposing a knob to modders before its owner has settled its range would be shipping someone else's undecided question as a feature.

no-test-needed: the ladder-as-content exposure already holds by construction — standard.json's buildRequirements.minIndustry is the only Industry-ladder field any set carries (schema.ts provides no multiplier or upgrade-cost fields at all), which is exactly this row's one permitted surface. The MUST-NOT side has no violation possible today because presets that could name the excluded knobs do not exist (§11's rule-preset content is unbuilt). It becomes testable when rule presets land: a preset naming Works multipliers or upgrade costs is refused until 02 declares legal ranges (MOD-1210).

MOD-240 Per-unit capability flags MUST be serialized as a flags object whose keys are drawn from the eighteen names of US-470 and whose values are booleans. The complete vocabulary, grouped as 02 groups it:

Group Flags
Ground and capture (US §3.1) capture, entrench, garrisonOnly, reactiveOnly
Transport and delivery (US §3.2) cargoLost, assaultLanding, airdrop, airbase
Fire support (US §3.3) siege, ambush
Detection and concealment (US §3.4) submerged, sonar, highAltitude, intercept
Special (US §3.5) expendable, administrator, emplace, supplySource

An unknown flag name MUST be a blocking error (US-470). An absent flag MUST mean false; the schema MUST NOT distinguish absent from false. The three flags US-490 classifies restrictive — reactiveOnly, garrisonOnly, expendable — MUST be marked as such in the published schema and in the editor, so an author reads them as costs rather than as features.

01-game-rules.md GR-1700 additionally asks 02 for a recon flag that its vocabulary does not yet carry. If 02 adds it the vocabulary becomes nineteen and this table follows; this document MUST NOT add it unilaterally.

no-test-needed: the flags object already conforms for the shipped set — CAPABILITY_FLAGS in packages/core/src/units/schema.ts is the closed eighteen-name tuple of US-470 with boolean values, unknown names refused by validation, absent meaning false. What has no subject yet is the author-facing half: restrictive-flag marking in a published schema and editor, both unbuilt (MOD-310). It becomes testable when authoring lands: an extra flag name is a blocking error, and the three US-490 restrictive flags read as costs in whatever UI presents them.

MOD-250 Transport MUST be serialized as the four independent fields of US-300 — slots, capacity, carryDomains, carryMaxSlots — and nothing else. What a carrier accepts is carried by carryDomains and carryMaxSlots; how much by capacity; what a unit costs to carry by slots. The schema MUST NOT provide a per-cargo-unit whitelist: US-310's loading test names domains and slot sizes only, and a whitelist by id would be a per-unit table the core would have to read by identity, which US-060 forbids.

A unit with capacity > 0 MUST carry cargoLost (US-540); a set violating this MUST fail validation, not warn.

no-test-needed: transport serialization already holds exactly — schema.ts types slots, capacity, carryDomains and carryMaxSlots as US-300's four independent fields and nothing else (no per-cargo whitelist exists to forbid), enforced for standard.json by validateUnitSet. The row becomes live when a second set can be authored: any added carrier field or cargo-name list is the violation, caught by MOD-180's schema agreeing with US-310's loading test.

MOD-260 Combat is eight integers, and the format MUST NOT offer a matrix. A unit's entire combat profile is hits, atkLand, atkSea, atkAir, def, flak, strikeRun and initiativeBase (US-230). The schema MUST NOT provide, and a loader MUST reject, any of: a per-target-unit attack table; a per-target-class attack table; a per-domain defence vector (US-250 forbids it outright); a per-terrain combat override on a unit record (terrain is a property of the map and 01-game-rules.md GR-380 hands 11-combat.md one number for it); or a modifier keyed on an opposing unit's id.

Rationale: the prior draft specified a full attacker × defender × terrain Combat Mod matrix, which for a twenty-unit set is four hundred cells before terrain and eight thousand after. US-240 names three attack integers as "the load-bearing simplification of the whole roster" and gives the reason: every counter relationship is readable off a card, and "this cannot touch that" is a zero in a column rather than a rule in a manual. A matrix would restore expressive power nobody asked for at the cost of the one property that makes a custom set comprehensible to the person playing against it — and it would make the non-domination test of US-1660 and the editor's counter-web view (MOD-295) impossible to compute or display. The expressiveness a matrix would have bought is available through the flag vocabulary, which is closed, tested and documented.

no-test-needed: the eight integers already hold — schema.ts's combat group carries exactly hits, atkLand, atkSea, atkAir, def, flak, strikeRun and initiativeBase per US-230 with no matrix field in the type for a document to fill; standard.json conforms. The MUST-NOT-offer-a-matrix half therefore stands by absence of schema surface. It becomes testable when authoring lands: a unitset.json carrying any per-target table or ninth combat number is refused with the field named.

MOD-270 Terrain interaction is not per-unit data. A unit record MUST carry exactly three terrain-related fields — domain, terrainClass and draft (US-120) — and the schema MUST NOT provide a per-terrain cost map, a passability map, a road-effect record, or a landing-terrain list on a unit record. A loader MUST reject a unit record carrying one, naming the field.

Movement costs live in the terrain set (§4) as one table indexed by movement class and terrain type, per GR-290. Passability is the absence of an entry in that table. Road, Fort and Airfield effects are Installation properties owned by GR-420 and are not per-unit.

Rationale: the prior draft put a full terrain map on every unit, which is the same value written unitCount × terrainCount times and drifting after the third edit. One table of 5 × 11 integers says the same thing exactly once, is what GR-290 actually specifies, and makes a custom terrain set composable with any unit set — which is the whole reason the two are separate content types. It also deletes an entire class of validation: with costs on the terrain side, "a unit that can enter nothing" is GR-370's cross-check rather than a per-unit invariant nobody runs.

no-test-needed: per-unit terrain data already does not exist — schema.ts carries no cost map, landing-terrain list or road-effect record (the type makes them unexpressible), and movement costs live where GR-290 puts them, indexed by the five classes of MOVEMENT_CLASSES in world/terrain.ts. It becomes testable when authoring lands: a unit record carrying any per-terrain cost data is rejected with the field named — the loader error this row promises.

MOD-280 Build requirements and capability parameters MUST be serialized exactly as US-350 and US-380 declare them:

Record Fields
buildRequirements (US-350) minIndustry, requiresCoastal, requiresTraits, minCityClass, minIntegration
Capability parameters (US-380) bombardRange, bombardShots, interceptZone, cityStrike, repairAdjacent, repairEmbarked, buildables, commandRating

requiresTraits MUST name traits from the closed vocabulary of 12-economy.md EC-460 and MUST carry at most three entries; buildables MUST name constructions from the closed vocabulary of 01-game-rules.md GR-420 (road, airfield, fort, bridge). A name outside either vocabulary MUST be a blocking error (US-730). commandRating MUST be permitted to be null and MUST be null in the shipped set (US-400); where non-null it overrides 13-command.md CM-850's span of 8.

There MUST be no separate "construction times" record anywhere in the format. 01-game-rules.md owns what an Installation costs to build; a unit either can build a thing or cannot.

no-test-needed: build requirements and capability parameters already serialize exactly as declared for the shipped set — schema.ts types US-350's five buildRequirements fields and US-380's eight parameters, standard.json conforms, and commandRating is null in it per US-400. The vocabulary checks this row adds (traits within EC-460, constructions within GR-420) run today as core validation of the one set. It becomes testable when authoring lands: a trait or construction name outside its closed vocabulary is a blocking error under US-730 applied to an external document.

MOD-290 Vision, detection and endurance MUST be serialized as the US-120 fields vision, visionAir and endurance, together with the four detection flags of US §3.4 and the interceptZone parameter of US-380. The schema MUST NOT carry a visibility whitelist naming which unit types may see a concealed unit: US-640 makes submerged visible to sonar units within vision and to adjacent units, which is a rule, not a per-unit list.

visionAir ≥ vision MUST hold for every unit (US-140) and MUST be a blocking validation error, not a warning.

no-test-needed: vision and endurance fields already hold — schema.ts carries vision, visionAir, endurance plus the detection flags of US 120 section 3.4 and the interceptZone parameter, with no visibility whitelist in the type for a set to ship; validation enforces visionAir at least vision as a blocking error per US-140. The row stands by conformance of standard.json plus absence of forbidden surface. It becomes testable when authoring lands: a named whitelist of unit types that may see concealed units is the violation — US-640's rule stays a rule.

MOD-295 The encyclopedia document. Because the unit schema is closed (MOD-225) and because US-1630 requires every unit's counter relationships to be shown on its card, a unit set package MUST be permitted to carry a companion encyclopedia.json mapping each unit id to:

Field Type Meaning
counters ordered list of unit id, class value, or localizable free text What this unit answers (US-1630, US-1640).
counteredBy same What answers it.
notes localizable text, optional Author's commentary; never read by the engine.

The encyclopedia MUST be presentation-only (MOD-150) and MUST NOT enter the sim hash. Where a set ships none, the client MUST still generate a complete encyclopedia from the stat lines and flags per US-2110 and US-930, and MUST label the counter section as derived rather than authored. An id naming a unit not in the set MUST be reported as a warning, not an error.

Rationale: US-1640 states the shipped roster's counter web as prose in 02, which is right for the shipped set and impossible for a custom one — and US-2110 requires the encyclopedia to be generated from the loaded set so that a custom set is documented in-game to the same standard. A separate presentation-only document squares those: an author who wants their web stated writes it, an author who does not gets a derived one, and neither can change what the simulation computes by editing prose. Keeping it out of unitset.json is what lets MOD-225 close that schema completely.

no-test-needed: no encyclopedia document exists and none can ship until packages do (see MOD-180's own row for the missing schema surface) — but both halves of its consumer already exist on the engine side: counter relationships are generated from the loaded set per US-2110 and US-930, which is exactly this row's derived fallback. It becomes testable when authored sets land: an authored encyclopedia.json stays presentation-only (out of the sim hash by MOD-150), a missing id warns rather than errors, and a set shipping none still shows a complete generated web labelled as derived.

MOD-300 A unit set MUST contain no executable code, no expression language, no formula strings, and no reference to a file outside the package (US-700, US-2050). A unit set MUST NOT be able to reach the plugin ABI of §8 in any way — not by naming a plugin, not by declaring a dependency on one, and not by carrying a field a plugin could read (US-2060).

Rationale: this is the boundary the whole document is organised around. A data set is safe to load from anywhere, deterministic by construction, and incapable of desyncing a multiplayer game; a plugin is none of those and belongs behind a separate, explicit trust decision. Blurring them would make every shared unit set a code-execution question, and the answer players would reach is "do not install unit sets", which costs the feature entirely.

no-test-needed: holds by absence in both directions — unit sets carry no code today because standard.json is pure data (schema.ts admits integers, strings and booleans only) and §8's plugin ABI does not exist for a set to reach anyway (see MOD-570's own row). The MUST-NOT-reach-the-plugin half therefore cannot fire: there is neither a field to carry nor an ABI to name. It becomes testable when both sides land: any unitset.json referencing a plugin by name, dependency or readable field is refused at validation.


4. Terrain sets

MOD-301 A terrain set MUST be a single JSON document (terrainset.json) validating against a published JSON Schema, carrying a terrainSetId (MOD-120), a guid, localizable name and description, a schemaVersion, and an ordered array of terrain type definitions. A terrain set is sim-affecting (MOD-040) and MUST enter the sim hash.

no-test-needed: terrainset.json does not exist as a document — the shipped terrain data lives as TypeScript (BASE_TERRAINS in packages/core/src/world/terrain.ts, eleven types per GR-260) with no JSON Schema published for it. The sim-affecting classification and sim-hash entry have their live analog already: unit-set identity entering the game hash under US-2090 is the pattern this row extends to terrain. It becomes testable when the document lands: it validates against a published schema, carries the metadata fields named here, and its bytes enter the sim hash.

MOD-302 Each terrain type MUST carry a stable id (MOD-120), a localizable name, a localizable one-line identity string, a family grouping used only for presentation, and exactly the six rules-visible attributes of 01-game-rules.md GR-350 — defence, block, view, sight, concealment, depth — as integers. The schema MUST NOT define a seventh rules-visible attribute: GR-350 states that no subsystem may read a terrain property outside that list, so a field the format carries and no rule consumes is a promise the engine cannot keep.

no-test-needed: the six attributes already hold exactly — world/terrain.ts's TerrainDef carries GR-350's defence, block, view, sight, concealment and depth as integers with no seventh rules-visible field in the type. What is absent is the document layer this row owns: stable ids and localizable names exist in data but have no terrainset.json envelope or schema to validate them by name. It becomes testable when §4's documents land: a seventh attribute is unexpressible, and every type carries its id plus GR-350's six integers.

MOD-303 Movement costs MUST be serialized as one table on the terrain set, keyed by terrain id and then by the five movement classes of GR-290 (foot, tracked, wheeled, naval, air), whose values are the integer entry costs. Absence of an entry MUST mean the class may not enter that terrain; the format MUST NOT carry a separate passability boolean, because two representations of one fact drift.

The table MUST cover the wheeled class even when no unit in any loaded set uses it, per GR-320.

Rationale: absence-means-impassable is the representation GR-290's own table uses, with — in the cell. Encoding it the same way means the shipped terrain set is a transcription of GR-290 rather than a re-derivation of it, and a reader can check one against the other by eye.

no-test-needed: the cost table already holds in engine form — world/terrain.ts indexes entry costs by terrain and by MOVEMENT_CLASSES' five classes with absence meaning impassable (the representation GR-290 itself uses), which is this row's serialization minus its document. The wheeled-class-must-cover clause stands for the base set; an authored table gets no such guarantee until validation exists. It becomes testable when terrainset.json lands: a missing class column or an extra passability boolean is the violation, named at load.

MOD-304 Installations and edge features MUST NOT be defined by a terrain set. The three tile Installations of GR-420 and the three edge features of GR-450, and every effect either carries, are rules. A terrain set MAY declare, per terrain type, whether each Installation may be built there — which is a parameter GR-420 already varies by terrain (the Airfield's foot entry cost ≤ 8 test) — and MUST NOT declare anything else about them.

no-test-needed: holds by absence — no terrain document defines Installations or edge features (they are rules of 01-game-rules.md, GR-420 and GR-450), and the permitted per-type buildability declaration has no writer yet either. It becomes testable when §4's documents land: a terrain set naming an Installation effect it does not own is refused; declaring where Airfields may stand stays legal because GR-420 varies that by terrain.

MOD-305 A terrain set MUST be validated at load against 01-game-rules.md GR-370 in full, and the loader MUST reject the whole set — never partially load it — naming every failure in one pass. GR-370 is authoritative and this document MUST NOT restate its bounds. The format adds exactly three checks of its own:

no-test-needed: the load-time whole-set rejection has no loader to act — terrain loads from TypeScript constants, so one-pass itemized failure reporting against GR-370 plus this row's three format checks (id uniqueness under case-folding, exact cost-table key coverage, at least one water and one land) has no subject. The underlying bounds validation already runs for the base set inside the engine's own world construction. It becomes testable when terrainset.json loads: a bad set never partially applies, with every failure named in one pass.

MOD-306 A terrain set and a unit set MUST be checked against each other at game setup, not at package install, because either may be installed without the other. The check MUST include GR-370's cross-condition — no movement class present in the loaded unit set may be left with no land terrain it can enter — and MUST additionally verify that every draft value in [1, 3] used by a loaded naval unit is reachable by at least one water terrain's depth (GR-590). A failure MUST block game creation with a named reason and MUST NOT be discoverable only after generation.

no-test-needed: cross-set checking at setup has no second set to check — standard.json is the only unit set and BASE_TERRAINS the only terrain set, so GR-370's cross-condition and GR-590's draft-versus-depth reachability are satisfied by construction for the one playable combination. It becomes testable when custom sets become selectable at setup: a movement class with no enterable land terrain blocks game creation with the named reason before generation starts.

MOD-307 Map generation presets MUST be terrain-set content: a terrain set MAY carry named terrainMix presets, each a table of per-cent shares over its own land terrain types summing to 100, serving the terrainMix setup parameter of GR-1520. A terrain set MUST carry at least one such preset, and the shipped set MUST carry the four GR-1520 names.

Rationale: a custom terrain set with no mix preset is a set no generator can use, because terrainMix is a setup parameter whose legal values are exactly the presets the loaded set declares. Requiring one is cheaper than specifying a default derivation nobody would like.

no-test-needed: the generator already consumes terrain-mix presets from its setup parameters (packages/core/src/world/mapgen.ts resolves GR-1520's terrainMix), and preset-sweep.test.ts pins the six landRatio presets of GR-1510 across size tiers — but the mixes live as engine data, not as named terrain-set content a custom set could carry. The SCOPE note in that same test file records exactly this gap: covering terrain-set mixes awaits §8's plugin ABI, which does not exist yet (it is cited there under MOD-307). It becomes testable when authored sets land: at least one preset per set, the four GR-1520 names on the shipped set, each summing to 100 over its own land types.

MOD-308 A terrain set MUST declare, per terrain type, the asset keys (MOD-460) its tiles are drawn from, and MUST NOT embed art. The depth attribute governs water assignment at generation per GR-1540 and MUST NOT be inferred from the asset.

no-test-needed: no terrain document declares asset keys because there is no asset-key registry and no art in packages (see MOD-460's own row) — the base set's tiles render from built-in procedural art keyed in code. The MUST-NOT-embed-art half stands by absence, as does depth-governs-water with nothing to infer from. It becomes testable when asset packs and terrain documents land together: every type names its tile keys (named tiles per MOD-460), and a set embedding raw art bytes is refused.


5. Content editors

MOD-310 The client MUST provide an in-game Unit Set editor and an in-game Terrain Set editor, each offering four screen-level operations: Review (read-only view of any set, including certified ones), New (blank, or a copy of an existing set), Edit (custom sets only; certified sets are read-only reference models), and Delete. Copying a set MUST copy its set-scoped assets automatically.

no-test-needed: no in-game editor exists anywhere under packages/client/src — the only content surface today is a setup screen whose unit-set control is a plain string field (draft.content.unitSet at packages/client/src/setup/model.ts), and the sets themselves are source-tree data rather than objects an author edits. It becomes testable when the editors land: Review of a certified set renders read-only, New copies including its set-scoped assets, Edit refuses certified reference models, and Delete acts on custom sets only.

MOD-320 The Unit Set editor MUST organize per-unit editing into panels that mirror the schema groups of MOD-230, MOD-240, MOD-280 and MOD-290, so that the editor's shape and the file's shape are the same shape:

Panel Contents
Identity id, name, role, class; asset binding (MOD-330); encyclopedia entry (MOD-295).
Movement & Vision domain, terrainClass, draft, move, vision, visionAir, endurance, zoc, mayOccupyCity; a live per-terrain cost readout computed from the loaded terrain set (§4), read-only.
Cost & Build worksCost, manpowerCost, costEscalationPerOwnedPerMille, buildRequirements; live turns-to-build against the reference producer of US-870.
Combat the eight integers of MOD-260, plus the live prediction of MOD-340.
Transport slots, capacity, carryDomains, carryMaxSlots; a live list of which units in the set this unit could carry and which could carry it, derived from US-310.
Capabilities the eighteen flags of MOD-240 with restrictive ones marked, and the eight parameters of US-380.

Every control MUST carry inline documentation — hover and expandable help — naming the requirement that owns the field, so that an author can read the rule rather than guess the behaviour.

no-test-needed: no editor exists to organize into panels; what stands is the shape the panels must mirror — packages/core/src/units/schema.ts declares the groups as closed const tuples and validate.ts checks them in one pass (US-710 through US-800), so the file's shape is already fixed by code. It becomes testable when the editor lands: each panel edits exactly its schema group, every control carries inline help naming the requirement that owns the field, and the live readouts compute from the loaded terrain set rather than re-stating it.

MOD-330 The Identity panel MUST let a unit reference another unit's art rather than its own, by naming an asset key (MOD-460) instead of relying on the default lookup by id (MOD-430). Add Unit MUST prompt for a unique id; Copy Unit MUST duplicate the selected unit under a new unique id and MUST NOT copy its sprites.json binding by default, since two units sharing art silently is the more surprising outcome.

no-test-needed: the Identity panel does not exist, and neither do asset keys (MOD-460) nor sprites.json bindings — nothing in the tree references another unit's art by key. It becomes testable when editors and the asset pipeline land together: Add Unit prompts for a unique id, Copy Unit duplicates under a new unique id without copying its sprite binding by default, and naming an existing unit's art by an asset key resolves to that unit's page rather than the default lookup.

MOD-340 The Combat panel MUST display, live as the integers are edited, the deterministic per-mille win prediction that 11-combat.md provides — the same value 13-command.md CM-150 and CM-2150 require for a sanctioned automated attack, and the same value a player is shown before a manual attack — for a selected attacker, defender and terrain. It MUST additionally show the expected number of rounds and the round-by-round survival distribution, and MUST label every figure as computed at full strength, in supply, with no Disorder, so nobody mistakes the editor's figure for a promise about a real engagement.

The editor MUST NOT implement its own combat arithmetic. It MUST call the core's predictor.

Rationale: an editor with a second implementation of the combat model is an editor that lies, and it lies most convincingly right after a balance change. Calling the core also means the prediction stays correct for a custom terrain set and a modded rule preset without the editor knowing either exists.

no-test-needed: the editor half is absent, but this row's engine obligation already stands in its strongest form — packages/core/src/combat/preview.ts implements 11-combat.md section 16 by calling resolveExchange itself (CB-2140) and reports exact per-mille counts with no Monte Carlo (CB-2240), so a preview can disagree with a resolution only if the specification differs. It becomes testable when the Combat panel lands: every figure it shows is that predictor's, labelled full strength in supply without Disorder, with rounds expected and round-by-round survival, and no second combat arithmetic exists anywhere under packages/client/src.

MOD-350 The editor MUST validate continuously and list problems inline. Blocking errors are exactly the core's own validation failures (US-710 through US-800) plus the format checks of §3, and the editor MUST surface them in the core's own words rather than paraphrasing:

Warnings, which MUST NOT block save-for-play, MUST include: a strictly dominated unit, naming its dominator (US-770 — a warning for custom sets and an error only for the shipped one, per US-770's own rule); a unit outside the 3-to-11-turn build band (US-780); a unit above the tier power ceiling (US-790); a flag or class no unit in the set carries (US-760 exempts custom sets); a sonar unit in a set with no submerged unit; and an encyclopedia entry naming a missing id (MOD-295).

Rationale for the split: we hold the shipped set to standards we deliberately do not impose on modders, and US-770 says so explicitly. A joke set, a teaching set with a strictly-worse trainer, or a historical set full of obsolete equipment are all legitimate and none of them should fail to load. Warning is the right strength; refusing would be paternalistic.

no-test-needed: no editor exists to validate continuously; what stands is the engine half this row would surface — validateUnitSet at packages/core/src/units/validate.ts implements US-710 through US-800 in a single pass with the core's own wording, and it already runs against any set that loads. It becomes testable when the editor lands: each blocking error appears inline verbatim from the core (a schema or type violation per US-720/MOD-230, an id collision per MOD-120), warnings never block save-for-play, and a strictly dominated unit stays a warning for custom sets while remaining an error only for the shipped one under US-770.

MOD-360 Both editors MUST provide unlimited undo/redo and a persistent per-set version history with named checkpoints and one-click restore. History MUST include the set's assets. Set identity is the guid (MOD-190); branching a set is done by Copy, which assigns a new guid.

no-test-needed: no editor exists whose operations could be undone, and nothing persists a per-set version history — the editor and its undo stack exist nowhere, and the one store this tree has (MP-160's game log) holds no set state at all — so both halves have no subject. It becomes testable when the editors land: undo/redo spans every operation of both editors without limit, and each set's history is persistent, browsable and restorable across client restarts.

MOD-370 Both editors MUST offer a side-by-side diff of any two sets, or two versions of one set, highlighting stat, flag and build-requirement differences, and — for unit sets — a counter-web view showing which units each unit beats and loses to, derived from the eight integers of MOD-260 against the loaded terrain set. The diff MUST additionally show which units moved between Industry tiers, since that is the edit most likely to change how a set plays and least likely to be visible in a numeric diff.

no-test-needed: no editor exists to diff from, and no stored versions exist to diff — nothing in the tree records more than one version of any set. The comparison substrate does stand: loadUnitSet at packages/core/src/units/roster.ts can hold two different validated sets side by side as ordinary data. It becomes testable when editors and version history land: a side-by-side diff of any two sets, or two versions of one set, names the changed fields, with certified reference models readable for it.

MOD-380 The client MUST provide a Rule Preset editor covering every parameter §11 declares settable, with plain-language descriptions per parameter, live legal ranges taken from the owning requirement, and an estimated-game-length readout for victory parameters supplied by 14-victory.md's estimator (VC §12.3). It MUST refuse to save a preset whose values fall outside a declared range, naming the parameter and the range.

no-test-needed: no Rule Preset editor exists, and no rule preset document can be read either — setup parameters today are fixed fields on the client draft (packages/client/src/setup/model.ts), not a key-value document of section 11's shape. It becomes testable when both land: the editor covers every parameter MOD-1010 declares settable, writes only keys that table allows, and shows each parameter's owning specification and legal range inline.

MOD-385 The Terrain Set editor MUST present the six GR-350 attributes and the movement-cost table of MOD-303 as one editable grid per terrain type, with a live preview of the resulting terrain defence value of GR-380 for a selected attacker elevation and river condition, and a live check of GR-370's bounds and of MOD-306's cross-set conditions against the currently loaded unit set.

no-test-needed: no Terrain Set editor exists; what stands is the data it would edit — BASE_TERRAINS (eleven types) with the five MOVEMENT_CLASSES at packages/core/src/world/terrain.ts, already in GR-350's per-type shape. It becomes testable when authoring lands: the six GR-350 attributes and the movement-cost table are editable per type for a custom set, validate under GR-370 as MOD-305 extends them with format-level checks, and certified sets stay read-only reference models.

MOD-390 Set-scoped assets — images, sounds, name pools — MUST live inside the set's package and be managed from its editor (import, preview, assign), with no manual file placement anywhere in the flow.

no-test-needed: no packages exist in which set-scoped assets could live — asset keys themselves do not exist yet (MOD-460), so scoping has no subject at all. It becomes testable when packaging lands per MOD-020/MOD-085: images, sounds and name pools ship inside the set's own package, take precedence over global packs per MOD-400, and deleting a set removes its scoped assets with it.


6. Asset packs

MOD-400 An asset pack maps asset keys (MOD-460) to files. Asset packs are either global (overriding or extending default art and audio for any content) or scoped (bundled inside a unit set, terrain set or map package and applying only there). Scoped assets take precedence over global packs, which take precedence over built-ins; ties among global packs resolve per MOD-100.

no-test-needed: no asset-pack machinery exists under packages/client/src — nothing maps asset keys to files, so the global-versus-scoped priority chain has no subject (built-in art loads straight from the client's own tree). It becomes testable when the pipeline lands per MOD-505: a scoped pack overrides a global one within its set, both override built-ins, and ties among global packs resolve by package priority exactly as MOD-100 orders them.

MOD-410 Image assets MUST be accepted as PNG (with alpha) or SVG, and MAY additionally be supplied as WebP or AVIF. Arbitrary dimensions MUST be supported. Atlas packing, mipmap generation, and any GPU texture-compression encoding belong to the install-time pipeline (MOD-505), never to the authored package. SVG MUST be sanitized and rasterized at install to the platform's device-pixel sizes per MOD-095: the WebGL map layer cannot consume vector art, so an SVG's benefit is resolution-independent authoring, not runtime scaling. Rasterization differs slightly between browser engines; that is acceptable precisely because image assets are presentation-only (MOD-150) and can never reach the sim hash. The published art guide SHOULD state the reference grid size used by the stock art so mixed packs compose well.

no-test-needed: no install-time image pipeline exists to accept PNG or SVG — the client loads its built-in textures with no authoring-format step anywhere in the tree. It becomes testable when intake lands per MOD-505/MOD-095: arbitrary dimensions are accepted, an SVG is sanitized and rasterized at device-pixel sizes before anything runtime sees it, and a presentation-only asset (MOD-150) never reaches the sim hash of MOD-140.

MOD-420 Player-colour theming MUST use a dedicated tint-mask channel or companion mask layer, authored as such. The format MUST NOT use chroma-keying — reserving particular pixel values as meaning "player colour" — because it makes those colours unusable in ordinary art and fails silently on a resampled or lossily-compressed asset. Two independently coloured regions MUST be expressible: a foreground and a background per player position.

Theming has two consumers and both paths MUST exist:

Both paths MUST produce visually identical output for identical inputs, and the pre-baked set MUST be regenerated when a position's colours change. The set is bounded by player count, not by palette, because colours are chosen at setup.

Rationale: splitting the map layer from the rest of the UI is what buys real tables, real text and real accessibility — but it means every piece of themed art has two renderers, and a divergence between them is a visual bug players report as "the icon in the production panel is the wrong colour". Naming both paths keeps that a known cost rather than a surprise. Two colours rather than one is what keeps eight positions distinguishable at a 24-pixel icon size, which is the size the Dispatch and the unit table actually use.

no-test-needed: the two-path theming this row requires does not exist — what stands today is a single-multiply tint path: packages/client/src/map/scene.ts bakes one multiply-tint page per seat and tier citing ART-180, with no mask channel, no custom shader and no pre-baked DOM rasters. It becomes testable when the pipeline lands: a dedicated mask expresses two independently coloured regions without chroma-keying or reserved pixel values, the shader path matches the baked path for identical inputs, and the baked set regenerates whenever a position's colours change.

MOD-430 Unit sprite selection MUST be declarative: a sprites.json whose entries are keyed by unit id (MOD-210) maps presentation states to asset keys. The presentation-state vocabulary MUST be exactly the following closed set, each entry derived from named simulation state and never from anything else:

State True when Defined by
normal always — the required default MOD-440
damaged hitsRemaining < hits US-410, US-430
loaded cargo is non-empty US-410
embarked carriedBy is non-null US-410
entrenched entrenchment ≥ 1 TM-700
disordered the unit's Disorder is above the threshold 11-combat.md publishes CB-1190
emplaced the unit has completed an emplace conversion US-680
lowEndurance enduranceRemaining ≤ 1 and endurance > 0 US-450
veteran veterancy ≥ 1 US-440
submerged the unit is submerged and undetected by the viewing player US-640

A client MUST NOT add a state to this vocabulary, and a sprites.json naming an unknown state MUST be rejected with the state name. Filenames are arbitrary and carry no meaning.

Rationale: the prior draft's five states were another game's display flags and three of them name nothing this simulation has. Deriving the vocabulary from state the specification actually carries means every entry is checkable against a requirement, and it means the fog rule on submerged is stated once, here, rather than discovered as an information leak in an art pack.

no-test-needed: no sprites.json reader exists in the tree — unit presentation state has no declarative vocabulary yet (the sim states it derives from all exist as live core concepts: US-410, US-430, CB-1190 and the rest named in its table). It becomes testable when authoring lands: an unknown presentation state is rejected with its name, a client cannot add to the closed set, and every entry stays derived from the simulation state it names rather than anything else.

MOD-440 Sprite resolution MUST be declaration order, first match wins: the client evaluates the unit's sprites.json entries in the order the document lists them, selects the first whose state conditions all hold, and falls back to the normal entry when none does. An entry MAY name several states, which MUST be conjunctive. Every unit MUST have a normal entry; MOD-350 makes its absence a blocking error. Where a unit has no sprites.json entry at all, the client MUST fall back to the asset key unit.<id> (MOD-460), and then to a generated placeholder that renders the unit's class glyph — never to a missing sprite.

Rationale: the prior draft specified a numeric bitfield searched over all subsets of the active flags in descending code order, which is a rule an author cannot hold in their head and cannot debug. Declaration order is a rule anyone who has written CSS already knows, it makes the author's intent explicit rather than emergent, and it makes the resolution trace displayable in the editor as a highlighted line. The cost is that an author must order their entries most-specific-first, which the editor warns about when an earlier entry shadows a later one completely.

no-test-needed: no sprite resolution exists to order — there is no declarative selection at all in the tree today (verified absence behind this note), so first-match-wins ordering, conjunctive multi-state entries and the fallback chain have no subject. It becomes testable when sprites land per MOD-430: declaration order decides, an entry naming several states holds only while all hold, a unit missing its normal entry fails as a blocking error under MOD-350, and a unit with no entry at all falls back to the asset key of MOD-460 before any generated placeholder.

MOD-450 City sprite selection MUST be keyed by the pair (Industry level, Integration band), and the bands MUST be declared in data so that a variant asset pack may redefine them. The Industry axis has exactly the four levels of US-810 and MUST NOT be redefined. The Integration axis MUST default to four bands over 12-economy.md's [0, 100] Integration — 0–24, 25–59, 60–89, 90–100 — giving sixteen states, and a pack MAY declare between 1 and 8 bands covering the range without gaps or overlaps. A city sprite entry MAY additionally name the presentation states garrisoned, besieged and unrest, resolved by MOD-440's rule.

Rationale: the two axes are the two facts about a city a player most needs to read at a glance from the map, and they are the two the design has made load-bearing — Industry decides what a city can build and is worth a two-hundred-tile raid (US-970), Integration decides what it actually produces and how fast a conquest digests. Four bands rather than eight because a player reading a map is distinguishing "raw, settling, working, ours" and not a percentage.

no-test-needed: no city sprite selection exists — nothing in the tree keys art by Industry level or Integration band; both axes themselves stand as live core data (US-810's four levels, and 12-economy.md's [0, 100] Integration range). It becomes testable when packs land: sixteen states from the default four bands with no gaps or overlaps, a pack may declare between one and eight bands over the range, and the Industry axis never moves off US-810.

MOD-460 All non-unit imagery — terrain, cities, Installations, rivers and fords, markers, map furniture, Landmark and Region markers — MUST be addressed by a published asset-key registry. Terrain art MUST be supplied as atlases with named tiles (for example woods.edge_n), never as positional sprite strips whose meaning depends on order. Named tiles are the authored form; the packed runtime form is the derived bundle of MOD-505, so no pack author ever hand-packs a sheet or depends on its layout. The registry MUST be versioned, and a key added in a later version MUST be optional so that an older pack keeps loading.

no-test-needed: no asset-key registry exists anywhere — built-in art is addressed by internal paths of the client's own tree, not by published versioned keys (verified absence behind this note). It becomes testable with the bundle format of MOD-505: every non-unit image resolves through a key in the registry, terrain tiles are named rather than positional strips, and a key added in a later registry version stays optional so an older pack keeps loading.

MOD-470 Audio assets: the client MUST support at minimum per-unit attack, defeat and movement sounds, assignable per unit id. Accepted authoring formats: OGG Vorbis, Opus, WAV, FLAC and MP3. Acceptance at authoring time is not playability at runtime: no single codec is decodable by every browser the client targets, so the install-time pipeline (MOD-505) MUST produce for each sound at least one variant the running platform can decode, and the client MUST choose a variant by capability probe rather than by file extension. A sound that cannot be decoded on the current platform MUST degrade to silence with a warning (MOD-500) — never fail the package, and never fail the game. Audio MUST load asynchronously on demand: adding sounds MUST NOT increase application startup time, which on a browser client additionally means decoding off the main thread and never blocking first paint.

The baseline variant the pipeline always emits MUST be one every target browser decodes; that codec is unsettled pending a confirmed decode matrix (Open question 8).

no-test-needed: there is no audio at all in this tree — zero use of AudioContext or decodeAudioData anywhere under packages (verified absence behind this note) — so per-unit attack, defeat and movement sounds, their authoring formats and the capability-probe rule have no subject. It becomes testable when the pipeline lands per MOD-505: variants are chosen by a capability probe rather than file extension, an undecodable sound degrades to silence with a warning under MOD-500 instead of failing the package or the game, and adding sounds never increases application startup time.

MOD-480 Random unit-name pools MUST be supplied as UTF-8 JSON ({"unit": "<id>", "names": [...]}), one pool per unit id (MOD-210). Default naming without a pool MUST be ordinal. Name pools are presentation-only (MOD-150).

no-test-needed: no random unit naming exists in-tree — units carry the definition name from standard.json and nothing draws a per-instance name from any pool. The machinery this row would feed already stands tested at packages/core/src/world/cities.ts (derivePositionalName takes an injectable pool, folds the seed deterministically, advances on collision and extends by ordinal when exhausted). It becomes testable when name-list packages land: one pool per unit id in MOD-480's JSON shape, classified presentation-only per MOD-150, with default naming staying ordinal where no pool is supplied.

MOD-490 Name-list assets MUST also cover AI player name pools and the game-name generator list, both replaceable and extendable via packages. Both are presentation-only.

no-test-needed: this row's engine half already stands and is exercised — derivePositionalName and deriveCityName at packages/core/src/world/cities.ts take name pools as parameters (GR-840, GR-1649) and mapgen already uses them for city names alongside the in-tree region pool; what is absent is an AI player name pool, a game-name generator list, and any package route that replaces or extends one. It becomes testable when name-list packages land per MOD-480's format: both lists load through ordinary presentation-only packages (MOD-150), replace or extend the shipped ones, and the derivation stays seed-deterministic under them.

MOD-500 Asset validation MUST run at install: decodability, declared-key coverage, size and budget limits (documented per platform), and mask-channel sanity, with itemized human-readable warnings and errors (MOD-080). Oversized assets degrade (downscale) with a warning rather than fail, except where a hard platform limit applies. On a browser client the budgets MUST additionally account for the storage quota of MOD-115 and for texture memory on a device the client cannot query directly; where a hard limit does apply — maximum texture dimension, quota exhaustion — the failure MUST be reported as an itemized, actionable error naming the offending asset, never as a silently missing sprite.

no-test-needed: no install-time asset validation exists to run — there are no packages, no decode step and no budgets enforced anywhere in the client (verified absence behind this note). It becomes testable when the pipeline lands per MOD-505/MOD-080: decodability, declared-key coverage, size limits and mask-channel sanity each report itemized human-readable results, an oversized asset downscales with a warning unless a hard platform limit applies, and that failure names the offending asset rather than surfacing as a missing sprite.

MOD-505 Compiled asset bundle (what the renderer actually loads). Installing a package that carries graphics or audio MUST produce a derived bundle alongside the stored archive: atlas pages and their frame index in the client's documented runtime atlas format, tint-mask channels and pre-baked tinted rasters (MOD-420), rasterized SVG at the platform's device-pixel sizes (MOD-410), and audio variants the platform can decode (MOD-470) — all addressed by asset key (MOD-460). The bundle is what the Phaser map layer and the DOM UI load at runtime; the archive is what the hashes of MOD-140 cover.

Rationale: with a web client the compile step is ours and, on a browser, it runs on the player's machine at install time — so it has to be bounded, resumable, and explicitly excluded from content identity, or the first launch after subscribing to a large pack becomes an unexplained stall.

Rationale for the atlas dimension rule: this clause previously required every page to be power-of-two and named mipmaps and compressed formats as the reason. Neither holds on the platform this project declares. Power-of-two sampling is an OpenGL ES 2.0 / WebGL1 restriction; WebGL2 — which UX-018 assumes — samples non-power-of-two textures with full mipmapping and wrapping, and the GPU compression formats want block multiples of 4, not powers of two. The constraint's one real beneficiary is the WebGL1 fallback of UX-385, which is where it now sits and where it can be retired with that tier rather than outliving it unnoticed. The cost it was hiding is concrete and lands on the device least able to pay it: rounding an 1100×900 page up to 2048×1024 roughly doubles its resident texture memory, which is the budget MOD-500 rations on a device the client cannot query and which 04-ui-ux.md UX-400 sizes for the mobile WebView — and "spill rather than downscale" multiplies that waste once per extra page. docs/art/00-art-direction.md ART-320 and docs/art/01-units.md ART-1030 state page dimensions against this requirement and MUST follow it; MOD-1305 records that obligation.

no-test-needed: no compiled-bundle machinery exists — nothing derives atlas pages, tints or audio variants from an archive (the client loads its built-in textures directly). The separation this row requires of derived-from-hashed already stands in miniature: US-2090's digest folds content definitions into every game hash at packages/core/src/units/digest.ts, which is exactly the boundary MOD-140 draws around hashed archives. It becomes testable when the pipeline lands: a bundle rebuilds from the archive alone with a pipeline version, never enters any hash (MOD-140), builds off the main thread with visible progress while its package stays selectable, and produces offline via the CLI.


7. Localization

MOD-510 Every displayed string in the client and in content MUST be externalized behind a string key; nothing user-visible may be hard-coded. The master string catalog MUST be published with the game.

no-test-needed: no string catalog exists — client strings are hard-coded in components and nothing externalizes them behind keys (verified absence behind this note); the only locale-dependent call anywhere in client src is a display sort. It becomes testable when the localization pipeline lands: every displayed string resolves through a key in the published master catalog, with an exhaustion check that any hard-coded user-visible literal is the violation to pin.

MOD-520 Localization files MUST be UTF-8 JSON mapping keys to ICU MessageFormat strings, with plural, gender and select support, one file per locale per package.

no-test-needed: no localization file exists anywhere — there are no locale JSON documents under any package (verified absence behind this note), so UTF-8 ICU MessageFormat conformance has no subject. It becomes testable with the first pack per MOD-560's tooling: one file per locale per package parses as ICU MessageFormat with plural, gender and select support, and a malformed file fails validation itemized rather than at runtime.

MOD-530 Partial overrides MUST be supported: a localization pack may supply any subset of keys; unresolved keys fall back per-key through package priority (MOD-100) to the base locale, then to English.

no-test-needed: no localization packs exist to override — there are no packages of any kind in the tree (verified absence behind this note), so per-key fallback through package priority has no subject. It becomes testable when authoring lands per MOD-100's priority order: a pack supplying any subset of keys falls back per-key through that priority to the base locale and then English, with an unresolved key never rendering as a raw identifier.

MOD-540 Text rendering MUST support full Unicode with automatic font fallback covering at minimum Latin, Cyrillic, Japanese, Chinese, Hangul, Arabic and Devanagari, including RTL layout for Arabic. See 04-ui-ux.md for typography. Fonts MUST be delivered as subsetted WOFF2 by script through the client's own asset pipeline and MUST NOT be fetched from a third-party font host — no package and no locale may introduce a network dependency (MOD-095), and offline play (MOD-900) must not silently lose a script. Text drawn onto the Phaser canvas MUST wait for the faces it needs to be ready before its first paint; unlike DOM text, canvas text does not repaint itself when a font finishes loading.

no-test-needed: no font pipeline exists — nothing ships WOFF2 or FontFace anywhere under packages (verified absence behind this note), so script fallback, RTL layout and canvas first-paint waiting have no subject. It becomes testable when the asset pipeline lands per MOD-410's routing rules: subsetted faces by script cover at minimum Latin, Cyrillic, Japanese, Chinese, Hangul, Arabic and Devanagari with RTL for Arabic, deliver through the client's own pipeline with no third-party font host (MOD-095), and canvas text waits for its faces before first paint.

MOD-545 Locale-dependent behaviour is presentation-only. Locale-aware collation, number and date formatting, and case mapping MAY be used freely for display, but MUST NOT determine any ordering, comparison, or value that reaches simulation state, a content hash (MOD-140), or a canonical serialization. Content keys and ids are compared by the case-sensitive, locale-independent rule of MOD-120, and every ordering the simulation observes is fixed elsewhere — unit definitions in ascending id (US-050), tiles by tile index (GR-140), entities by sequence number (VC-180).

Rationale: one language now spans client and server, with one string type and locale-sensitive comparison available from anywhere in it. A list sorted for display in one locale and then hashed in another is a content mismatch (MOD-160) that reproduces only for some players, which is the most expensive kind to diagnose.

no-test-needed: this obligation already holds in the only locale-sensitive call anywhere in-tree — a display sort using localeCompare, whose docblock records that the engine never uses it — and there is no surface yet where a violation could occur, since every ordering that reaches simulation state, a content hash (MOD-140) or canonical serialization is fixed elsewhere: units by ascending id (US-050), tiles by index (GR-140). It becomes testable when localizable display strings land per MOD-550: nothing locale-aware may determine an ordering the simulation observes, and content ids compare by the case-sensitive rule of MOD-120.

MOD-550 Mod content strings — unit name and role, terrain names, rule preset text, Doctrine names, Terms text, editor text — MUST be localizable by key through the same mechanism. Free text typed by players in-game (unit renames, map labels, city names) is stored verbatim and never machine-localized.

no-test-needed: both halves await content — no mod content exists whose unit names, terrain names or editor text could be localizable (verified absence behind this note), and there is no in-game free text yet either (no rename or label surface ships). It becomes testable when authoring lands per MOD-510's mechanism: every named mod string resolves by key through the same catalog as client strings, while player-typed free text stores verbatim and never passes through machine localization.

MOD-560 Tooling: the client MUST export a translation kit (all keys, source strings and comments) and re-import completed translations; a pseudo-locale (expanded, accented) MUST exist for layout testing. Community translation packs are ordinary presentation-only packages published through the workshop.

no-test-needed: no translation tooling exists — there is nothing to export a kit from or re-import into (verified absence behind this note), and no pseudo-locale to exercise layout. It becomes testable when the catalog lands per MOD-510/MOD-520: exporting yields all keys with source strings and comments, re-import validates against that same catalog, a pseudo-locale exists and visibly expands or accents, and community packs publish as ordinary presentation-only packages through the workshop.


8. WASM plugin ABI

MOD-570 Behavioral extensions — AI players and map generators — MUST be sandboxed WebAssembly plugins. Native code loading MUST NOT be supported in any form. Plugin kinds at v1 are exactly ai and worldgen. Two further kinds are reserved and MUST NOT be loadable in v1: scenario-script (§9) and victory (MOD-1120).

no-test-needed: no plugin host exists to sandbox anything — behavioral extensions run today as ordinary in-process TypeScript on both hosts, and each seam records it (packages/server/src/ai-seat.ts and floor-seat.ts), so there is no WASM module, native-code path or kind list to police. It becomes testable when the section 8 ABI lands: ai and worldgen load through the platform's own WebAssembly API, scenario-script and victory are refused as reserved kinds (MOD-1120), and no code path under any package loads a non-WASM behavioral extension.

MOD-575 Plugin host. Plugins MUST be executed by the platform's own WebAssembly implementation — the standard WebAssembly API in a browser, the same API in Node — through one shared host module used by both. No WASM runtime is embedded, bundled, or compiled into the product, and there MUST NOT be a second execution path for any platform: a plugin the server runs MUST run identically in a browser tab, because client preview and authoritative resolution share the same core. The host is the client or the server, never @everylastcity/core: the core is a pure package with no DOM, no Node APIs, and no I/O, so it can neither instantiate, sandbox, nor meter a module. The host owns the WebAssembly API surface, the worker boundary, and the metering of MOD-610, and hands plugin results to the core as ordinary data.

Each plugin instance MUST run in a worker (a Web Worker in a browser client, a worker_thread in Node) that the host can terminate unilaterally at any point — and the worker holds more than the instance. Every §8 ABI import is a synchronous WASM call, so answering view.*, orders.submit, or a MOD-590 PRNG draw from another thread would require a blocking cross-thread round trip: SharedArrayBuffer + Atomics.wait (and therefore cross-origin isolation) or JavaScript Promise Integration. 06-ai.md AI-235 forbids the ABI depending on either by name; 03-architecture.md AR-860 makes cross-origin isolation a feature-detected optimization the client MUST work without. The host therefore materializes into the worker exactly what the plugin's imports must be answered from, in-thread:

Plugin kind Materialized in the worker by the host Crossing the message boundary
ai The WASM instance plus a @everylastcity/core replica, materialized from authoritative state at the start of the Orders phase for that seat (06-ai.md AI-235) State materialization in; the seat's order records for that turn out, which the authority appends to the order log (10-turn-model.md TM-200)
worldgen The WASM instance, the MOD-640 request record, and the mapgen draw cursor whose draw functions 03-architecture.md AR-400(a) requires the host to own Request in; the MOD-640 result and the advanced mapgen cursor out, so the stream remains host-owned

The prohibition is therefore on authority, not on data: a plugin worker MUST have no handle to the authoritative simulation context, to the document, to the network, or to storage, and the plugin module itself MUST reach the host only through the vetted imports of MOD-590. Host functions MUST be supplied as an import object the host constructs, never assembled from anything the package provides (MOD-095), and any host-side glue between the ABI and the engine — marshalling shims, generated bindings, worker plumbing — is part of the ABI surface and MUST NOT close over ambient host capabilities. A core replica MUST be materialized only where the host already legitimately holds that state: a client MUST NOT materialize one for a seat it does not own, and in a fog-enforced online game the AI's Orders phase runs server-side.

Rationale: the browser makes an embedded runtime both unnecessary and unjustifiable — the engine is already present, already sandboxed, and is the best-tested WASM implementation on the machine. What the platform does not give us is that runtime's control surface: fuel metering, NaN canonicalization and feature gating were engine settings and are now our responsibility, which is why MOD-590, MOD-600 and MOD-610 carry weight they did not previously carry. The replica is not a second source of truth — because orders, not state, cross the boundary, the authoritative core re-derives everything it is handed.

no-test-needed: no host module exists — nothing instantiates WebAssembly anywhere in src; the only WASM mentions left in code are docblock seams at packages/server/src/ai-seat.ts and floor-seat.ts plus MP-120's consent wording at packages/core/src/net/pin.ts (verified absence behind this note). It becomes testable when the shared host lands: each instance runs in a worker the host can terminate unilaterally, a core replica materializes only where the host already legitimately holds that state (a client never for a seat it does not own), and one plugin behaves identically in a browser tab and on the server.

MOD-580 Plugin interfaces MUST be defined in WIT and versioned as empire:ai@X.Y.Z and empire:worldgen@X.Y.Z over a shared empire:host@X.Y.Z. WIT is the definition and code-generation source; the artifact a package ships and a host instantiates is a core WebAssembly module, whose imports and exports are derived from the WIT world by a single published, versioned canonical lowering (Canonical-ABI value encoding over the guest's exported memory and allocator). The SDK performs that lowering at build time, so the bytes that are published are the bytes that run and the bytes that are hashed (MOD-145). A host MUST NOT transpile, componentize, or synthesize glue from package bytes at load time. The host MUST declare its supported ABI version ranges; a plugin outside the range MUST be refused with a clear message naming both versions. ABI evolution follows SemVer: additive = minor, breaking = major; the host SHOULD support the current and previous major for at least one release cycle.

Rationale: no browser instantiates a WASM component natively, and the usual workaround — transpiling a component into core modules plus generated JavaScript glue — would mean synthesizing script from untrusted bytes inside the client's own origin, which MOD-095 forbids outright. Fixing the lowering at build time keeps WIT's authoring and versioning benefits, keeps exactly one artifact per plugin, and keeps the host's load path down to instantiate-plus-a-fixed-import-object, which is small enough to audit.

no-test-needed: no WIT world, SDK or canonical lowering exists — nothing publishes, derives from or instantiates an ABI artifact (verified absence behind this note), so build-time lowering has no subject yet. It becomes testable when @everylastcity/plugin-sdk lands per MOD-670: the bytes a package ships are the bytes that run and hash (MOD-145), a version outside the host's declared range is refused with a clear message naming it, and load time performs nothing but instantiate plus the fixed import object.

MOD-590 Sandbox: plugins MUST have no filesystem, network, clock, thread, or environment access. The only capabilities are the host imports: logging, the host-owned seeded PRNG stream, read access to the fog-filtered view appropriate to the plugin's role, and order or result submission. WASI MUST NOT be exposed at all: the sanctioned import set is exactly the empire:host functions of the negotiated ABI version, and a module importing anything else MUST be rejected at load, naming the offending import. SDK templates MUST therefore build against a WASI-free target; where a guest toolchain unavoidably emits an abort or diagnostic-write import for panics, the host MAY satisfy exactly the documented shim pair (trap, diagnostic log) and nothing further.

Because a browser's WebAssembly engine cannot be configured to withhold capabilities or features the way an embedded runtime could, this rejection MUST be performed by the host's own static validation of the module bytes — at install, and again before instantiation. The engine will cheerfully instantiate anything it supports; refusal is now our code, not a runtime flag. One implementation of that validator MUST serve the client, the server, and the workshop's pre-publication scan (MOD-880).

no-test-needed: no module loader exists whose imports could be vetted — there is no static validator of plugin bytes anywhere in the tree because there are no plugins (verified absence behind this note). Its context does stand: MP-120's one-time per-plugin consent wording already lives at packages/core/src/net/pin.ts. It becomes testable when the host lands: a module importing anything outside the sanctioned empire:host functions is rejected at install and again before instantiation, naming the offending import, by the same validator that serves client, server and the workshop scan of MOD-880.

MOD-600 Determinism: given identical inputs and PRNG stream, a plugin invocation MUST produce identical outputs on every platform. Under the standard WebAssembly API this MUST be achieved by constraining the module, not by configuring the engine:

All randomness MUST come from the host PRNG, which the core seeds and logs (03-architecture.md AR-350, AR-370).

no-test-needed: the boundary this row governs does not exist yet — no value crosses an ABI because nothing instantiates a plugin module. What already holds in miniature is its AI-surface instance: the determinism comments at packages/client/test/ai-determinism.test.ts cite MOD-600 and MOD-580's no-float rule for today's in-process AI, and performance-strategy.test.ts carries the same discipline. It becomes testable when real plugins land: the permitted-feature list records with the game configuration, NaN bit patterns never reach a returned value, and cross-engine differential output matches byte-for-byte as a release gate.

MOD-610 Resource limits:

Rationale: this is the single place where dropping the embedded runtime costs real work. Fuel was a runtime feature we would have switched on; it is now a compiler pass we own, version and must test. The compensation is that the metering is then identical in the browser, in Node and in the CLI, because it lives in the module rather than in whichever engine happens to be running it. The line between mandatory and optional instrumentation is drawn at verification evidence, not at convenience: solo play may skip the tax, and pays for it by not counting as proof of anything.

no-test-needed: no instrumented module exists to meter — today's server AI runs in-process without fuel (the recorded gap behind this note), so no deterministic budget is in force anywhere. It becomes testable when the instrumentation pass lands per MOD-670(b): fuel exhaustion traps at the same instruction on every platform, wall clock serves only as a non-deterministic watchdog and never shapes a result, budget introspection reports fuel and never wall time, and the pass id with version records in the game configuration alongside the fuel values.

MOD-615 Derived plugin artifacts — the instrumented module of MOD-610, any host-side compiled form, and cached compiled-module objects — are derived, never published, and never hashed. MOD-140's digests cover the bytes in the archive, so instrumentation and compilation caches may change with a client update without changing a plugin's identity (MOD-130) or its multiplayer compatibility (MOD-160). Hosts SHOULD compile streaming where the platform offers it and MAY cache compiled modules keyed by (module hash, instrumentation version); a cache MUST be invalidated when either changes, and a cache miss MUST cost nothing but time.

no-test-needed: nothing is instrumented or compiled yet — there are no plugin modules, so derived artifacts have no subject (the digest machinery that would exclude them stands in miniature at US-2090). It becomes testable when the first plugin lands per MOD-610: an instrumentation cache miss costs nothing but time, a rebuilt module never changes the plugin's identity (MOD-130) or multiplayer compatibility (MOD-160), because MOD-140's digests cover archive bytes alone.

MOD-620 Failure containment: a plugin trap, fuel exhaustion, or watchdog termination MUST NOT crash or corrupt the game.

AI plugin faults are owned by 06-ai.md AI-240, which specifies the fault handling, the safe fallback for the remainder of the turn, and what happens after repeated faults; this document MUST NOT state a different threshold or a different action. Whatever orders the fault path issues on a seat's behalf enter the authoritative order log attributed by the Issuer field of 03-architecture.md AR-260, never by an ad-hoc flag, so the difference between a fallback turn and a substituted seat is legible in the log itself. A seat whose AI plugin faults MUST NOT freeze: 10-turn-model.md TM-290 guarantees that a player who submits no order records at all still has a well-defined turn, because every unit executes its persisted order stack — which is the correct fallback and needs no invention.

Worldgen faults are owned here: a trap, fuel exhaustion, or an invalid result aborts generation with the plugin's diagnostics shown at setup (MOD-640), leaves the map unchanged, and MUST leave the setup screen usable — a failing generator MUST never be the reason a game cannot be started with a different one.

On both hosts a trap surfaces as an exception at the host call boundary and MUST be caught there. A plugin that is instead unresponsive — wedged, looping, or running uninstrumented under MOD-610's local-interactive path — MUST be recoverable by terminating its worker (MOD-575), and that termination MUST be handled exactly as a trap, so what a player sees never depends on how the failure was detected.

no-test-needed: no plugin can trap — there is no host boundary to fail across (verified absence behind this note); in-process faults surface as ordinary exceptions instead, and a worldgen fault path of the shape this row names does not exist yet either. It becomes testable when the ABI lands: a trap or worker termination is handled exactly identically on both hosts with AI-240 owning every AI-side outcome, an invalid worldgen result aborts generation showing diagnostics at setup while leaving the map unchanged, and the setup screen stays usable for another generator.

MOD-630 Options: every plugin MUST export options-schema() returning a JSON Schema describing its tunable parameters, and default-options(). The setup UI renders the schema generically (04-ui-ux.md). Option configurations MUST be savable and loadable as named presets, MUST be shareable, and MUST be recorded with the game so a replay knows exactly which options were active.

no-test-needed: no plugin exists to export a schema — setup today renders its own fixed fields from packages/client/src/setup/model.ts with no generic options surface (verified absence behind this note). It becomes testable when plugins land per MOD-670: every plugin's options-schema and default-options drive the setup UI without bespoke rendering code, option presets save, load, share, and record with the game so a replay knows exactly which were active.

MOD-640 Worldgen interface. generate(request) -> result, where:

request = { width, height, topology, playerCount, startCities, seed: u64, terrainSetRef, setupParams, optionsJson }. topology MUST be one of the three values of 01-game-rules.md GR-190 (flat, cylinder, torus) — not a pair of independent wrap booleans. setupParams MUST carry the generation-relevant parameters of GR-1480 (landRatio, terrainMix, cityDensity, minCitySpacing, riverDensity, roadDensity, neutralDefence, minStartSeparation). seed is host-supplied and derived per 03-architecture.md AR-400(b); a generator MUST NOT obtain entropy from anywhere else, and under MOD-590 it has nowhere else to obtain any.

result MUST carry:

Field Content Owner of the rules
terrain the tile grid, terrain ids from the active terrain set, row-major by tile index GR-140, GR-260
edges River and Ford edge features GR-450, GR-1550
installations pre-generated Roads (and nothing else at generation) GR-420, GR-1480 roadDensity
cities per city: name, class, traits, Industry, Population, Landmark marker, Region index EC-460, US-810, GR-850
regions M named, disjoint Regions partitioning every city, each with a regional capital GR-1030, VC-3110
landmarks L Landmarks with proper names, L odd GR-1080, VC-3110
starts each player's startCities start cities GR-1570

The host MUST validate the result before use and MUST reject it with diagnostics when it violates any map invariant 01-game-rules.md states — GR-310 (every land tile foot-enterable), GR-850 (no city on Mountains, Marsh or Icefield), GR-1550 (a Ford per river chain of 4 or more), GR-1560 through GR-1620 (the fairness invariants), and GR-1540's depth assignment. A generator that cannot satisfy them MUST be retried with the next PRNG state up to GR-1560's budget of 64 attempts and MUST then fail loudly.

Rationale: the invariants are 01's and the retry budget is 01's, but the enforcement point is here, because a third-party generator is precisely the code that will not have read them. Validating the result rather than trusting the generator is what lets an untrusted plugin produce a playable map at all — and it is why the invariant list is stated as citations rather than restated, so that a change in 01 fails the validator rather than silently diverging from it.

no-test-needed: the worldgen interface has no subject — today's maps come from in-process calls to packages/core/src/world/mapgen.ts with parameters passed directly, not through a request-and-result contract an untrusted module could violate. What it would enforce on plugins already pins the stock generator by its own titled test (packages/core/test/world/preset-sweep.test.ts runs it across every shipped preset, size tier and player count). It becomes testable when real generators land: a result violating GR-310, GR-850, GR-1550, GR-1540 or any fairness invariant of GR-1560 through GR-1620 is rejected with diagnostics, retried up to GR-1560's budget of 64 attempts, and then fails loudly.

MOD-650 Worldgen determinism and preview: an identical request, including seed and options, MUST yield an identical map for a fixed generator build (03-architecture.md AR-400). The editor and game setup MUST render live previews by running the real generate call, so preview equals final output; seeds MUST be displayed, re-rollable, lockable and shareable. Preview MUST run in the plugin's worker (MOD-575) and MUST be cancellable, so an expensive generator cannot freeze the setup screen or the editor; a superseded preview's result MUST be discarded rather than raced onto the canvas.

no-test-needed: no preview machinery exists — there are no plugin workers to run generate in, nothing cancellable, no seed controls beyond setup's own display (the generation seed is already shown on the setup screen with copy and share per UX-170). The determinism half it promises holds for today's generator: output is a pure function of the setup and its seed. It becomes testable when the ABI lands: an identical request yields an identical map under AR-400, preview runs the real generate call inside the plugin's worker (MOD-575) and stays cancellable without racing onto the canvas.

MOD-660 AI plugin transport. This document owns the transport; 06-ai.md owns the decision-making interface, the shipped roster and the budget classes. The transport MUST be shaped by the turn model of 10-turn-model.md and not by sequential turn-taking:

  1. Lifecycle. create → start-game → (per turn) prepare-orders → game-over, with serialize and restore around saves and loads. One instance per seat, memory-isolated from every other instance (06-ai.md AI-340).
  2. Per-turn invocation. The host MUST invoke prepare-orders once per turn, during that seat's Orders phase, against a fog-filtered read-only view of the board as it stands at the start of that phase — which TM-340 guarantees is stable for the whole phase. The plugin MUST NOT be invoked during the Cascade, MUST NOT be invoked between two units' activations, and MUST NOT be shown any Cascade result before the Reckoning completes.
  3. Order submission. The plugin MUST submit order records through the same Order API a human client uses (TM-200, TM-210, 03-architecture.md AR-245), and the host MUST append them to the authoritative order log. An illegal order MUST be rejected at submission with the typed rejection of TM-250 and MUST NOT crash or end the seat's turn. An order that is legal when issued and unachievable at activation MUST NOT be reported as an error at any point (TM-270).
  4. Persistence. Plugin-private memory blobs MUST be persisted with the save as separate container entries outside hashed simulation state (06-ai.md AI-460), and MUST be droppable without affecting replay (AI-465).
  5. Parity. An AI seat MUST receive no extra time, no extra information and no extra order records relative to a human seat (TM-410), and MUST commit within the same Orders phase.

Rationale, and the change: the prior draft specified an AI that drove a whole turn synchronously, submitting an order, observing its resolution, and deciding the next one — which is exactly the sequential model the binding turn-model decision replaced. Under Orders and the Cascade there is nothing to observe mid-phase, because nothing resolves until every seat has committed. The compensation is real and is the reason the model was chosen: TM-280 makes orders persistent conditional policies, and 13-command.md's Postures and Sanctions are the conditional vocabulary an AI writes its plan in, so a plugin commits a policy that a late-initiative unit re-evaluates against everything that happened earlier in the Cascade. An AI that ports the old shape will find it has one call where it expected thirty, which is why MOD-1290 asks 06-ai.md to restate its own §3 and §5 against this requirement.

no-test-needed: the transport exists only in degraded form — both hosts run an AI per seat today through direct calls with no create/start-game lifecycle, no serialize around saves and no worker boundary (packages/server/src/ai-seat.ts, floor-seat.ts) — the server's copy since 2026-09-29 keeps its advisory memory across turns on disk (AI-460's sidecar beside MP-160's container, packages/server/src/seat-memory.ts), but calls it in-process. What it would submit into already stands: the order log of TM-200/TM-210 with typed rejections per TM-250 exists in core. It becomes testable when the plugin host lands: one prepare-orders invocation per Orders phase against TM-340's stable board, an illegal order rejected at submission without ending the turn, and full parity with a human seat under TM-410.

MOD-670 Developer experience. The plugin SDK MUST be published as @everylastcity/plugin-sdk and MUST provide all three of the following.

(a) Language templates. This document owns the SDK language-template roster; 06-ai.md AI-260 states the AI-specific surface of the same roster and MUST name the same set, so a change here MUST be reconciled there rather than diverging silently. Every template MUST emit a core module conforming to MOD-580's canonical lowering and MUST pass MOD-590 and MOD-600 validation unmodified, out of the box. First-class means maintained in-repo, built in CI, and shipped with the generated ABI bindings, the determinism lint and fuel-profiling output. The v1 roster:

Template Standing
AssemblyScript First-class. Its documentation MUST say plainly that AssemblyScript is a TypeScript-shaped language with its own type system and standard library, not TypeScript — @everylastcity/core types are not importable into a plugin, and the ABI's typed records are what an author codes against. The SDK ships AssemblyScript implementations of the integer helpers the boundary needs rather than re-exporting the core's.
Rust First-class. The smallest and fastest modules and the most mature WASM toolchain; the reference implementation of each stock plugin SHOULD be one of these.
TinyGo MAY ship as a community-contributed template on the same conformance terms. Not a release gate.

Any toolchain that emits a conforming module declaring only sanctioned imports is loadable, on the plugin author's own maintenance; 06-ai.md AI-265 states the same rule from the AI side. A genuine-TypeScript template — one carrying an embedded JavaScript engine inside the guest — is deliberately not in the v1 roster and MUST NOT be advertised as one: it costs a second determinism surface and a large per-turn fuel bill, and MOD-885's per-module cap would have to be re-derived against a real build of it first (Open question 10).

(b) Local build and test tooling, including a headless harness that runs a plugin against recorded games. The harness runs on Node and MUST be the same code path the cross-engine differential job of MOD-600 drives, so a failure an author can reproduce locally is the failure CI reports.

(c) A developer mode that watches an unpacked package and hot-reloads data and assets live, and plugins at the next turn boundary. Watching a directory needs a filesystem, so this mode is full on the desktop build and degraded in a browser tab: a browser client MUST support it by loading the unpacked package from a local development server over HTTP with change notification, and MAY additionally use a user-granted directory handle where the browser offers one. Unpacked packages are uncertified and excluded from every hashed context per MOD-145.

no-test-needed: no @everylastcity/plugin-sdk exists in this tree — no language templates, no headless harness and no dev mode (verified absence behind this note). It becomes testable when it is published: each v1 template emits a core module conforming to MOD-580's canonical lowering that passes MOD-590 and MOD-600 validation unmodified out of the box, the Node harness drives the same code path as MOD-600's cross-engine job so local reproduction equals CI failure, and dev mode hot-reloads an unpacked package at the next turn boundary with uncertified exclusion per MOD-145.

MOD-680 The stock map generators MUST ship as open-source worldgen plugins, and MUST between them cover the six landRatio presets of GR-1510 and the terrain mixes a terrain set declares (MOD-307). At least one stock generator MUST expose an option restricting generation to a named subset of the loaded terrain set's types, so that a map of only open ground is expressible without authoring a second terrain set. Stock generators MUST satisfy GR-1560's invariants within the retry budget on every shipped size tier and player count, and that MUST be a CI gate rather than an aspiration.

Rationale: GR-1590 and GR-1620 exist precisely because a generator that occasionally strands a player on a peninsula is a generator that decides a tournament. Making the invariants a gate on the stock generators rather than a validation the host performs after the fact is what stops the retry budget from being consumed on every generation.


9. Map & scenario editor

MOD-690 The editor MUST distinguish two artifacts: a map — geography, Regions, Landmarks and neutral cities, with player starts chosen at setup — and a scenario — a map plus seat definitions, optional pre-placed units, and any victory parameters the scenario fixes (§11), where nothing is randomly generated at game start. Fog and the intelligence model still apply to a scenario per the setup parameters GR-1630 owns.

no-test-needed: no editor exists to hold either artifact — nothing under packages/client/src distinguishes a map from a scenario (the absence is itself asserted in the test suite for this reason). It becomes testable when the editors land: a scenario carries seat definitions, optional pre-placed units and any victory parameters it fixes with nothing randomly generated at game start, while a bare map still takes its player starts at setup under GR-1630's fog rules.

MOD-700 New-map flow: the author sets dimensions within GR-120's [16, 2048] range and a topology from GR-190's three values, then either (a) generates via any installed worldgen plugin, or (b) creates an empty map filled with a single chosen terrain type. The editor MUST show the size tier a chosen dimension pair corresponds to, or state that it lies between tiers (GR-240), and MUST warn when either axis exceeds 1024 that the map is outside the tested performance envelope (GR-250).

no-test-needed: no new-map flow exists — dimensions today are chosen through the setup screen's presets (packages/client/src/setup/map-presets.ts), not by an author setting them against GR-120's [16, 2048] range and GR-190's three topologies. It becomes testable when the editor lands: out-of-range dimensions are refused, the size tier of a dimension pair is shown or stated to lie between tiers per GR-240, an axis over 1024 warns of the untested performance envelope per GR-250, and both generation paths (plugin or empty single-terrain map) start from this flow.

MOD-710 Map palette: paint any terrain type from the loaded terrain set with a square brush sized 1×1 through 10×10; erase to a designated erase terrain; place and remove the three tile Installations of GR-420 (Road, Fort, Airfield) subject to their own placement rules; place and remove the edge features of GR-450 (River, Ford, Bridge) on tile edges. A defaults panel MUST govern fill terrain, erase terrain and brush settings.

The palette MUST NOT offer any object outside GR-420 and GR-450 (MOD-185). Where 01-game-rules.md adds one, this list follows.

no-test-needed: no map palette exists — there is no brush, no defaults panel and no editor surface under packages/client/src (verified absence behind this note). The vocabulary it would offer already stands as data in core: the terrain types of BASE_TERRAINS at packages/core/src/world/terrain.ts, GR-420's three Installations and GR-450's three edge features. It becomes testable when the editor lands: paint, erase and place all act within that vocabulary only (MOD-185) subject to each object's placement rules, and nothing outside it is offered.

MOD-720 City editing: per-city editable attributes MUST be exactly name, city class (town / city / metropolis), traits (0–3, from EC-460, within the per-class range EC-320 declares), Industry level in [1, 4] (US-810), Population, Landmark marker (GR-1080), and Region assignment (GR-1030). The editor MUST enforce EC-460's trait restrictions — Shipworks and Free Port coastal-only, Farmland and Quarry mutually exclusive — at edit time with a named reason, and MUST enforce GR-850's terrain restriction on city placement.

no-test-needed: no city editor exists — cities today are what the generator produced (packages/core/src/world/mapgen.ts) with no authoring surface for name, class, traits or Industry level. The restrictions themselves already stand as core enforcement: EC-460's trait rules and GR-850's terrain rule apply to any city record that reaches game setup. It becomes testable when the editor lands: every restriction fires at edit time with a named reason (Shipworks and Free Port coastal-only, Farmland and Quarry mutually exclusive) rather than only at load.

MOD-730 Region and Landmark editing MUST be first-class, because 14-victory.md's Hegemony (T4), The Keys (T5) and Ascendancy (T6) are unplayable without them and GR-1030 requires Regions to partition every city. The editor MUST show, live: the count of Regions M and of Landmarks L; whether L is odd (GR-1080); any city assigned to no Region; and any Region with no regional capital. Each MUST be a blocking publish error, and MUST be a warning during editing.

no-test-needed: no first-class Region or Landmark editing exists — there are no live counts of M and L anywhere in the client. The invariants themselves already hold for generated maps (preset-sweep exercises them across the shipped configuration space) and core enforces them against any world it accepts. It becomes testable when the editor lands: a city assigned to no Region, an even Landmark count per GR-1080, or a Region without a regional capital is each a warning during editing and a blocking publish error before shipping.

MOD-740 Map-edit tools: add and remove outer rows and columns; symmetry generation producing horizontally, vertically or quad-symmetric maps; and a live symmetry-painting mode that mirrors brush strokes across the selected symmetry in real time. Symmetry operations MUST respect topology: a cylinder map's symmetry across the wrapping axis is a rotation, not a mirror, and the editor MUST say which it is doing.

no-test-needed: no resize, symmetry-generation or mirror-paint tools exist anywhere in the client (verified absence behind this note). It becomes testable when the editor lands: add/remove of outer rows and columns preserves tile-index order per GR-140, quad-symmetry mirrors brush strokes live as painted, and a cylinder map's wrapping axis is labelled a rotation rather than a mirror.

MOD-750 The editor MUST provide unlimited undo/redo covering every editor operation — terrain, installations, edges, cities, Regions, Landmarks, seats, units, visibility, resizes and pastes — with a browsable history list.

no-test-needed: no editor exists whose operations could be undone — there are no brush strokes, resizes or pastes anywhere in the tree today (verified absence behind this note). It becomes testable when the editors land: undo/redo spans every operation of both editors without limit, and the history list is browsable enough to jump to an earlier state rather than merely step.

MOD-760 Copy and paste: rectangular region select; selective paste of terrain, installations, cities, and units-and-seats layers; rotation of the clipboard in 90° steps; horizontal and vertical flip; multiple named clipboard slots; and an on-map paste preview before commit.

no-test-needed: no copy or paste exists — there are no clipboard slots, region select or on-map paste preview anywhere under packages/client/src (verified absence behind this note). It becomes testable when the editor lands: selective paste of terrain, installations, cities or units-and-seats layers each touches only its layer, 90-degree rotation and flips stay correct against GR-140's row-major tile-index order, and a preview shows exactly what a commit will do.

MOD-770 Scenario palette: per-seat initial tile knowledge (marked tiles enter that seat's Terrain Record at game start per 01-game-rules.md §9, after which the normal vision and decay rules apply); per-seat Seat designation, which is the capital city 14-victory.md's Throne Track (T14) and its Tenure penalty key on; and unit placement under a designer-selected unit set.

The scenario MUST NOT be hard-bound to that set. At game time, placed units MUST be matched by id only; a placed unit whose id is absent from the game's selected unit set MUST be reported at setup and omitted, and the editor's validation (MOD-800) MUST list every such unit before publish. There MUST be no secondary matching key.

Placed units MUST support editing of owner, carrier, hitsRemaining, veterancy and name without delete-and-replace, and MUST be placeable only where 01-game-rules.md §5 and §6 permit that unit to be — stacking limits (GR-720) included.

Rationale for dropping the fallback match: the prior draft matched a placed unit by id and then by a legacy short code, so a scenario silently played differently under a set that happened to reuse a two-letter code. id is globally namespaced by setId (US-070) and stable across versions (US-080); one key that either matches or does not is a scenario an author can reason about.

no-test-needed: the scenario palette has no subject — and its absence is already watched from the other side: packages/client/test/tutorial.test.ts asserts that the core package exposes no scenario machinery at all, with its refusal reason naming MOD-770. What remains untested is everything this row adds once scenarios exist: per-seat initial tile knowledge entering a seat's Terrain Record at game start, per-seat Seat designation under 14-victory.md's Throne Track, unit placement matched by id only that reports every missing id at setup before publish, and edit-in-place of placed units without delete-and-replace.

MOD-780 Seats: each playable seat MUST define a foreground and a background colour (MOD-420), an Industry cap in [1, 4] (US-900), a starting Works grant, and — where the scenario enables Charters — a Charter selection from the pool of 14-victory.md VC-1630. Seat icon generation MUST run in the background without blocking editing. A scenario's seat count fixes the setup seat list: seats MUST NOT be added or removed at setup when using a scenario.

Rationale for the Industry cap sitting on the seat: US-900 makes it a per-player value set at setup and US-920 makes it a handicap on roster complexity rather than on production, which is exactly the kind of asymmetry a designed scenario wants — a tutorial scenario capping the learner at Depot and the AI at Workshop is one integer per seat, and it is the only handicap in the design that does not lie about the board.

no-test-needed: no scenarios exist to define seats for — seat setup today is the ordinary new-game flow of packages/client/src/setup/model.ts, where nothing is fixed per scenario (no per-seat Industry cap, no Charter selection, no fixed seat list). It becomes testable when the editor lands: each playable seat carries foreground and background colours per MOD-420, an Industry cap in [1, 4] under US-900, a starting Works grant and, where Charters are enabled, a selection from VC-1630's pool; seat icons generate in the background, and a scenario's seat count fixes the setup list with no add or remove at game time.

MOD-790 Generator integration: the editor MUST host live worldgen preview (MOD-650) with an editable, copyable seed, parameter controls rendered from the plugin's options schema (MOD-630), side-by-side comparison of candidate seeds, and named save/load of generator configurations. Generated output lands in the editor as ordinary editable content, and the editor MUST retain the generator provenance 03-architecture.md AR-400 requires.

no-test-needed: no generator hosting exists inside an editor — what stands today is generation itself: world/mapgen.ts runs from the setup flow against its setup and seed, and the seed is already displayed on the setup screen with copy and share per UX-170. It becomes testable when the editor lands: live preview runs the real generate call (MOD-650) with an editable re-rollable seed, parameter controls render from MOD-630's options schema, candidate seeds compare side by side, configurations save and load by name, and generator provenance survives per AR-400.

MOD-800 Playtest-from-editor: one action MUST launch a playable game from the current editor state — solo, or versus chosen AI plugins — and return to the editor with undo history intact. Before playtest or publish the editor MUST run validation covering, as warnings during editing and as errors before publish: GR-1560 through GR-1620's fairness invariants where the map is intended for random starts; MOD-730's Region and Landmark completeness; placed-unit ids absent from the selected set; seat count against the scenario's own victory Terms; and any victory parameter outside the range 14-victory.md VC-470 declares.

no-test-needed: nothing can launch a playtest because no editor state exists — the setup screen is today's only game-start surface (packages/client/src/setup/model.ts). The validations it would run already stand in core as checks on whatever configuration reaches createGame, for instance resolveVictorySetup at packages/core/src/victory/setup.ts rejecting an out-of-range victory parameter by name under VC-470 rather than clamping. It becomes testable when the editor lands: one action launches a playable game solo or versus chosen AI plugins and returns with undo history intact, and every listed check is a warning during editing but a blocking error before publish.

MOD-810 Map and scenario file formats MUST be documented JSON manifests. Each tile layer MUST be serialized in row-major tile-index order (GR-140) in a form satisfying all three of the following properties, and the format MUST NOT mandate a codec beyond them:

(a) Exact round-trip. Decoding and re-encoding a legal tile grid MUST reproduce it value for value.

(b) One canonical byte form per grid. An encoder MUST have no freedom of expression: two authoring tools writing the same grid MUST produce identical bytes. Maps and scenarios are sim-affecting (MOD-040) and enter the sim hash, and MOD-140 canonicalizes a document on its own textual form — so an encoding admitting two spellings of one grid hands two clients different sim hashes for identical content, which surfaces only as a MOD-160 content mismatch no player can explain.

(c) Decodable at the rules maximum. Decoding MUST be one pass, linear in tile count. Above the Large tier of 03-architecture.md AR-690 it MUST land directly in the runtime typed array AR-710 stores the layer in, with no intermediate object or boxed value per tile: GR-150's ceiling is 4,194,304 tiles and a browser tab is the tightest target the memory budget of AR-720 has to hold.

Exactly two encodings MUST be documented, and a conforming reader MUST accept both:

Form Encoding Used when
In-manifest One JSON array of one integer per tile per layer, in tile-index order at or below AR-690's Large tier (1,000,000 tiles)
Externalized chunk A fixed-width little-endian unsigned integer per tile, in tile-index order, stored as an ordinary archive entry and referenced from the manifest by path, element width and tile count MUST be used above the Large tier; MAY be used at any size

Element width MUST be declared per layer and MUST be the width that layer's value domain requires — one byte for terrain, because 01-game-rules.md GR-370 caps a terrain set at 256 types precisely so that AR-710 can hold TerrainId in a Uint8Array. A layer whose element count disagrees with width × height (GR-120, GR-140) MUST be a blocking validation error (MOD-080), never a silent resize; that check is also what keeps a hostile map inside MOD-085's intake ceilings, because the allocation is known before the first tile is read.

The chunk MUST NOT carry compression of its own. MOD-020 already requires the containing archive to use store or deflate and a browser client to decode it through the platform's own DecompressionStream, so a self-compressed chunk compresses the same bytes twice — and deflate output is not canonical, so two tools compressing one grid at different settings would defeat (b) at exactly the point where MOD-140 hashes the entry.

Rationale: this requirement previously mandated run-length encoding of every tile layer and gave no reason for it — the shape of an assumption carried over from an era with no general-purpose compressor available at load time. It fails all three properties above. It is not canonical: a run of five identical tiles is as legally written 2+3 as 5, so two editors produce byte-different documents, different sim hashes, and a MOD-160 mismatch for an identical map. It defeats random access, so every brush stroke (MOD-710), row insert (MOD-740), paste (MOD-760) and undo step (MOD-750) becomes decode-mutate-re-encode. And it does not buy size: MOD-020 stores the package as a deflate ZIP and deflate's own LZ77 stage already encodes runs, so run-encoding first usually costs bytes rather than saving them. Both forms above are canonical by construction and need no run-splitting rule adjudicated between tools. The size arithmetic that fixes the tier boundary: at the Large tier the in-manifest array is roughly 3 MB of text; at GR-150's maximum it is roughly 12 MB, which exceeds MOD-885's 8 MiB map cap on its own, and its transient parse result is one JavaScript number per tile where the runtime form is a 4 MiB Uint8Array. The chunk form is 4,194,304 bytes flat and deflates far below the cap. A compact in-JSON encoding MAY still be added later, but only with a single canonical spelling fixed in the format and every other spelling of the same grid made a MOD-080 error.

Embedded metadata MUST include author, dimensions, topology, seat count, M, L, and the unit-set and terrain-set dependencies as (id, version range) pairs, so services can index content structurally rather than by parsing display names. Scenarios declare their unit set and terrain set as normal package dependencies (MOD-050) while remaining loadable under other unit sets per MOD-770.

no-test-needed: no map or scenario file exists to format — games start from in-memory generated maps (packages/core/src/world/mapgen.ts) and nothing loads tile data from a document, so none of the three serialization properties has a subject yet. It becomes testable when authoring lands: exact value-for-value round-trip, two tools producing byte-identical encodings of one grid (the property MOD-140's hashing depends on), one-pass linear decode landing in AR-710's typed array above the Large tier of AR-690, and a chunk that carries no compression of its own so intake under MOD-085 stays single-stage.


10. Workshop

MOD-820 A single in-game workshop MUST cover all content kinds of MOD-040. The workshop is reachable from the Mod Manager, from game setup, and from the editors.

no-test-needed: no workshop exists anywhere in the client — nothing under packages/client/src browses, lists or installs content beyond what ships built-in (verified absence behind this note; setup's only content control is the plain-string unit-set field). It becomes testable when the service and its UI land per 08-services-platform.md: one workshop reaches every content kind MOD-040 names, and it is reachable from the Mod Manager, from game setup and from the editors.

MOD-830 Publishing MUST be an in-game flow requiring a cloud account (08-services-platform.md); offline and guest play never require one, publishing does. The publish form captures name, description, tags and screenshots; structural facts — dimensions, topology, seat count, Region and Landmark counts, content types, dependencies and hashes — MUST be extracted from the package automatically. Map and scenario publishes SHOULD auto-generate a minimap thumbnail. Display names MUST support full Unicode.

no-test-needed: no publishing exists — the server exposes two WebSockets plus a plain-HTTP /healthz endpoint and nothing persists an item past process exit (recorded at packages/server/src/floor-seat.ts), so there is no cloud-account flow to exercise. It becomes testable when SVC lands per 08-services-platform.md: the in-game publish form captures metadata while structural facts are extracted from the package automatically, display names survive full Unicode, and offline or guest play never prompts for an account.

MOD-840 Versioning: workshop items are updated by publishing a new SemVer version under the same package id by the same owning account. Published versions are immutable (MOD-130); each version carries a changelog. Subscribers MUST be notified of updates and choose per-item auto-update or pinning; in-progress games keep the exact versions they started with.

no-test-needed: nothing is published, so nothing has a version to update — no SemVer history, changelog or subscription state exists anywhere (verified absence behind this note). It becomes testable when the workshop lands: republishing under an unchanged id by the same owning account yields a new immutable version per MOD-130 with its changelog, subscribers choose auto-update versus pinning per item and see updates resurface, and an in-progress game keeps exactly the versions it started with.

MOD-850 Discovery: browsing by creator MUST be supported, plus full-text search, filters by tag/type/size/seat count, and sorting by rating, downloads and recency. Downloaded and subscribed state MUST be tracked per account server-side; already-subscribed items are marked, and updated items resurface. Workshop content MUST also be browsable on the web.

no-test-needed: no service exists to browse — there is no catalog, search index or per-account state anywhere (the server persists nothing past process exit). It becomes testable when SVC lands: browsing by creator, full-text search, filters by tag/type/size/seat count and sorting by rating, downloads and recency all act on real items; subscribed-and-downloaded marks are tracked per account server-side with updated items resurfacing, and the same catalog browses on the web.

MOD-860 Installing a workshop item MUST resolve and fetch its dependency closure (MOD-050) with a single confirmation listing everything to be installed — subscribing to a scenario pulls its unit set and terrain set automatically.

no-test-needed: no dependency resolution exists — nothing in the tree declares package dependencies (the setup unit-set field is a plain string at packages/client/src/setup/model.ts), so a closure to fetch has no subject. It becomes testable when the manager lands per MOD-100/MOD-050: one confirmation lists everything an install pulls, and subscribing to a scenario automatically resolves its unit set and terrain set with versions verified by hash before start under MOD-160.

MOD-870 Multiplayer integration: when a lobby's game uses content a joining player lacks, the client MUST offer one-click download of the exact required versions, verified by hash per MOD-160, before the game starts. Self-hosted servers MUST be able to serve required content directly to joiners for games they host, so private content never requires the public workshop. A server serving content to browser clients MUST satisfy MOD-125's origin requirements; content delivered without readable CORS headers cannot be hashed (MOD-145) and MUST be refused rather than installed unverified, and the client MUST say so in those terms rather than reporting a generic download failure.

05-multiplayer.md MP-120 additionally requires a one-time per-plugin consent prompt before any sandboxed WASM plugin is fetched for a game; data content is fetched without one. That distinction is MP-120's and this document MUST NOT weaken it.

no-test-needed: the verification seam of this row already stands — packages/core/src/net/pin.ts implements the MP-110/MP-120 verdicts against content hashes, and the lobby carries those hashes in its state (packages/server/src/lobby.ts); what is absent is every fetch path behind them, since the server serves no files at all today. It becomes testable when distribution lands: a joiner lacking required content gets one-click download of the exact versions verified by hash before the game starts, a self-hosted server can serve its own private content without the public workshop, and delivery failing MOD-125's origin rules is refused in those terms rather than as a generic download failure.

MOD-880 Moderation basics: every item MUST carry its license (MOD-030) and an in-app Report control. The service MUST support a takedown and appeal process including DMCA, delisting (hidden from discovery; existing subscribers and in-progress games keep functioning) as distinct from deletion (malware or illegal content only), and automated pre-publication scanning: schema validation, asset decode checks, size-cap enforcement (MOD-885), and WASM static verification that plugins import only the sanctioned host interface (MOD-590). Operational policies live in 08-services-platform.md.

no-test-needed: no service exists to moderate — no items carry licenses, no Report control exists and there is no takedown path anywhere (the server persists nothing). Its one testable half comes ready first: once plugins exist, static verification that a module imports only the sanctioned host interface per MOD-590 becomes the pre-publication pin. It becomes testable when SVC lands per 08-services-platform.md: every item carries its license and a Report control, takedown/appeal with DMCA distinguishes delisting (subscribers keep functioning) from deletion (malware or illegal content only), and automated scans enforce MOD-885's caps before listing.

MOD-885 Publication size caps. This document owns the per-content-type upload limits that 08-services-platform.md SVC-485 and SVC-490 delegate here. Every publish MUST be rejected before listing, with a human-readable message naming the offending item and both the actual and permitted size, when any of the following is exceeded. Defaults are instance-configurable within documented bounds, and the official instance's effective values MUST be advertised by the service so the client can pre-check locally before spending an upload:

Scope Default cap
.elcmod archive, total compressed 512 MiB
.elcmod archive, total decompressed 2 GiB
Any single file within the archive, decompressed 256 MiB
Overall decompression ratio (zip-bomb guard) 100:1
Unit set (unitset.json + sprites.json + encyclopedia.json, data only) 4 MiB
Terrain set (terrainset.json, data only) 1 MiB
Rule preset, command library, or victory Terms preset 256 KiB each
Map 8 MiB
Scenario 16 MiB
Localization pack, per locale file 4 MiB
Name list, per pool 1 MiB
Graphics asset pack 256 MiB
Audio asset pack 256 MiB
WASM plugin module (ai, worldgen), per module 32 MiB
Manifest manifest.json 256 KiB
Screenshots, each / per item 8 MiB / 10

Caps are a publication control and are distinct from MOD-500's per-platform install-time asset budgets, which govern client memory and VRAM and degrade by downscaling rather than rejecting, and from MOD-085's intake limits, which bound what any archive may expand to before it is trusted. Sideloading (MOD-900) is not subject to these caps — they protect the hosting service, not the engine; MOD-085 is what protects the engine.

The 32 MiB per-module figure is measured on the uncompressed module as stored in the archive, and is satisfiable by every template of MOD-670(a): an AssemblyScript module for a stock-scale AI is well under 1 MiB and a Rust one is single-digit MiB, so the cap is sized for the largest case the roster admits. It sits inside the 256 MiB per-entry decompressed cap and cannot on its own approach the 2 GiB archive total, so the three limits are mutually satisfiable as written.

The unit-set and terrain-set figures deserve a note, because they moved: with the combat matrix and the per-unit terrain map deleted (MOD-260, MOD-270), a twenty-unit set is roughly forty integers and two dozen strings per unit and comfortably under 100 KiB. 4 MiB is therefore not a constraint on any plausible set but a guard against a generated or accidentally-duplicated document, which is the only way a data file of this shape gets large.

Per-account aggregate publisher quota is the service's concern (08-services-platform.md SVC-490).

Rationale: SVC-485 and SVC-490 both delegate size limits to this document. The numbers are engineering estimates pending calibration against a real stock-scale asset pack (Open question 6); the decompressed-size and ratio guards, not the per-type figures, are the security-relevant ones.

no-test-needed: no service exists to enforce publication size limits — there is no publish flow that could spend an upload, so every cap in the table has no enforcement point yet (the engine-side intake limit of MOD-085 likewise has no subject until archives can arrive). It becomes testable when SVC lands per 08-services-platform.md: each row of the table rejects a publish before listing with a human-readable message naming the offending item and both actual and permitted sizes, effective values advertise from the service for local pre-checks within documented bounds, and sideloaded files bypass the caps exactly as MOD-900 exempts them.

MOD-890 Self-hosting and portability: the workshop service MUST be part of the self-hostable @everylastcity/server; a self-hosted instance runs its own independent catalog. Any item a user owns MUST be exportable as its .elcmod file, and sideloading that file into any instance MUST work (MOD-900) — which is how content moves between instances in v1. Cross-instance workshop mirroring MUST NOT exist in v1: 08-services-platform.md SVC-980 excludes federation, and SVC-840 forbids a self-hosted instance contacting the official service as a side effect of normal operation. Automatic mirroring is deferred to the post-v1 federation revisit that SVC-980 anticipates.

no-test-needed: no workshop service exists inside @everylastcity/server — today's server is its two WebSocket services plus /healthz with no catalog to make self-hostable (verified absence behind this note). The negative half of this row already holds trivially in a tree whose only external dependency is ws: nothing can mirror across instances because no instance holds any items. It becomes testable when SVC lands under SVC-980 and SVC-840: an owned item exports as its .elcmod, sideloads into another independent instance per MOD-900, and no cross-instance mirroring exists in v1.

MOD-900 Sideloading MUST always work: installing a .elcmod from local disk requires no account and no network. A browser client has no disk, so there "from local disk" means the user handing the client the file: a file picker and drag-and-drop onto the Mod Manager MUST both work, and MUST work while offline. Sideloaded content is validated identically (MOD-080), bounded by MOD-085 (the MOD-885 publication caps do not apply to it), hashed on receipt (MOD-145), and participates in multiplayer verification by hash like any other content. All installed content MUST be fully usable offline — which on a browser client additionally obliges the application shell itself to be offline-capable via a service worker (Open question 12).

no-test-needed: no sideloading path exists — nothing reads a .elcmod from user hands yet (verified absence behind this note). Its offline-shell half already stands: packages/client/src/sw registers a service worker in production builds, so the application itself is offline-capable. It becomes testable when intake lands per MOD-020/MOD-085: file picker and drag-and-drop both install while fully offline without an account, validation runs identically (MOD-080), bounds apply from MOD-085 with MOD-885's publication caps not applying, and the content hashes on receipt like any other under MOD-145.

MOD-910 Abuse resistance: package ids are first-publish-owned per account; the service MUST prevent id squatting takeovers, rate-limit publishes, and require re-verification when ownership transfers. Ratings MUST be limited to accounts that downloaded the item.

no-test-needed: no account or service exists to squat — package ids, publish rates, ownership transfers and ratings have no subject anywhere in this tree (the server persists nothing past process exit). It becomes testable when SVC lands: first publish owns an id per account with squatting takeovers prevented, rate limits bite a burst of publishes, ownership transfer demands re-verification, and only accounts that downloaded an item may rate it.


11. Rules, command, and victory as content

The three subsystems whose vocabularies are closed, and whose parameters are not. This section is where the modding promise is at its narrowest and its statement of the boundary therefore matters most.

11.1 Rule presets

MOD-1000 A rule preset MUST be a single JSON document carrying a presetId (MOD-120), a guid, a localizable name and description, and a flat map of parameter keys to integer values. A rule preset is sim-affecting (MOD-040) and MUST enter the sim hash.

no-test-needed: no rule preset document can be read — today's setup parameters are fixed fields on the client draft (packages/client/src/setup/model.ts), not a JSON document carrying presetId, guid and a flat map of parameter keys to integers. The hashing half of this row already stands in miniature: US-2090's digest at packages/core/src/units/digest.ts folds content definitions into every game hash, which is the machinery a sim-affecting preset would enter through. It becomes testable when the format lands per MOD-1010: a preset loads only with keys its table allows and enters the sim hash of MOD-140 exactly as unit sets already do.

MOD-1010 A rule preset MUST be permitted to set exactly the parameters the owning specifications declare settable, and no others:

Group Parameters Owner
Map and generation every entry of the GR-1480 table — size, topology, landRatio, terrainMix, cityDensity, minCitySpacing, riverDensity, roadDensity, neutralDefence, startCities, minStartSeparation, fogEnabled, intelligenceDecay 01-game-rules.md
Turn model the Orders deadline of TM-480 10-turn-model.md
Industry only where 02-units-and-industry.md declares a range (MOD-235) 02-units-and-industry.md
Victory every Track parameter VC-470 declares settable, plus Charter Mode, Warrant Mode, SO, and the Horizon H 14-victory.md

A key outside this table MUST be rejected at load with the key name. A value outside the range its owner declares MUST be rejected at load with a named error, per VC-470 and GR-1490 — never clamped, never defaulted.

Rationale: clamping an out-of-range value is the failure mode that produces a game nobody configured and nobody can explain. Both owners say so independently — GR-1490 requires rejection before generation is attempted, VC-470 requires rejection at load — and this document's only job is to make the file format incapable of expressing the thing they forbid.

no-test-needed: no preset loader exists to reject anything — there is no reader for rule preset documents anywhere in the tree (verified absence behind this note). The parameter table itself stands as data it would police: GR-1480's generation entries, TM-480's Orders deadline and VC-470's victory parameters each have an owning specification with declared ranges. It becomes testable when the loader lands: a key outside the table is rejected at load with its name, a value outside its owner's range fails as a named error per GR-1490 and VC-470, never clamped and never defaulted.

MOD-1020 A rule preset MUST NOT be able to change any closed vocabulary of MOD-185, any formula, any threshold not listed in MOD-1010, or the classification of any content type under MOD-150. A preset that would need one of those is asking for a rules change, and a rules change is a RulesVersion bump (03-architecture.md AR-880), not content.

no-test-needed: nothing exists that could change a closed vocabulary — there are no rule presets in the tree to carry such keys (verified absence behind this note), so the prohibition has no subject yet. What makes it checkable when they arrive is that every protected object stands as fixed data today, including MOD-185's closed vocabularies and US-720-style rejection of unknown fields. It becomes testable when the first preset lands: any attempt to express a formula change, an unlisted threshold or a MOD-150 reclassification is inexpressible in the format itself, and the only door out stays a RulesVersion bump per AR-880.

11.2 Command content

MOD-1030 The Posture vocabulary (CM-330), the Sanction set (CM-570), the Reflex set and priority (CM-290, CM-300), the Stance values (TM-630) and the order stack depth (CM-230) are closed to content. No package may add, remove, rename or re-parameterise any of them. CM-330 and TM-630 both state that adding a value is a rules change requiring a version bump, and CM-2060 requires any change to a Posture algorithm, a Sanction test, a tie-break, a threshold or a default to bump commandLogicVersion — which a mod cannot do, because a mod cannot make a replay recorded under it replayable.

Rationale, stated because this is the answer people will not expect: the command layer looks like the most obviously moddable thing in the game and is the least. Its automation runs in the simulation core and is therefore replay-relevant (CM-100, CM-2050), so a seventh Posture is not a preference, it is a fork of the rules that silently invalidates every recorded game. What makes this acceptable rather than merely restrictive is the shape CM-570 chose: four orthogonal Sanctions over six Postures is a combinatorial space far larger than a longer list of named behaviours, and that space is fully open to content through MOD-1040.

no-test-needed: the vocabularies this row closes already stand as fixed data inside the simulation core (the command layer lives there — for instance packages/core/src/turn/sanction-precedence.ts), but its prohibition binds content packages and none exist yet, so nothing in-tree can violate it. It becomes testable when command content lands per MOD-1040: no package document admits a seventh Posture, an added Sanction, or any re-parameterisation of the Reflex set or order stack depth — the closedness is structural in the format rather than rejected after the fact, because CM-2060 makes any such change unreplayable.

MOD-1040 A command library MUST be a JSON document carrying named Doctrines — each a bundle of a Posture, all four Sanction values, an optional Formation role and an optional Stance override, exactly as CM-770 defines a Doctrine — plus an optional default-Doctrine mapping from unit class (US-090) to Doctrine name, serving CM-790. It is sim-affecting (MOD-040), because a Doctrine is authoritative game state (CM-770) and a default Doctrine determines the orders a newly built unit carries.

A command library MUST NOT carry a unit-specific Posture parameter — a particular Screen line, Survey region or March route — because CM-810 forbids a Doctrine from expressing one.

no-test-needed: no reader exists for command library documents — Doctrines today are created and edited only through the client's own doctrine surfaces (packages/client/src/doctrine), with no distributable JSON form. The state they would carry stands as authoritative game data in core per CM-770. It becomes testable when libraries land per this section: a library carries named Doctrines exactly as CM-770 defines them plus an optional class-to-doctrine default mapping serving CM-790, enters the sim hash because it is sim-affecting under MOD-040, and refuses any unit-specific Posture parameter that CM-810 forbids.

MOD-1050 Installing a command library MUST NOT alter any unit in a running game. A library supplies a starting Doctrine set for a new game and a set the player may import into a running one; importing MUST be an explicit player action and MUST follow CM-800's rule that editing a Doctrine does not retroactively retask units already created under it, offering the counted apply to the N units currently on this Doctrine action instead.

Rationale: CM-800 calls silently retasking three hundred units the fastest possible way to destroy trust in automation. A shared Doctrine library that did it on install would be that, arriving from a stranger.

no-test-needed: no command library can be installed — there are none to install (verified absence behind this note). The rule this row wraps already stands in core as the behaviour CM-800 requires: editing a Doctrine does not retroactively retask units created under it. It becomes testable when libraries land per MOD-1040: installing alters no unit in a running game, importing stays an explicit player action on that running game, and apply offers only the counted action for the N units currently on the Doctrine rather than silent wholesale retasking.

MOD-1060 A command library MAY additionally carry named Templates and Requisition rows (CM-1240, CM-1310) keyed on unit class, so that a shared library can supply a complete production and posting policy rather than only a set of dials. Requisition rows keyed on unit id MUST be rejected, per US-100.

no-test-needed: no command library exists to carry Templates or Requisition rows — nothing in the tree reads such a document (verified absence behind this note), so both halves have no subject yet. It becomes testable when libraries land per MOD-1040: named Templates and Requisition rows keyed on unit class load as part of a complete production and posting policy, and a row keyed on unit id is rejected at load under US-100 rather than silently mis-resolving.

11.3 Victory content

MOD-1070 A victory Terms preset MUST be a JSON document naming which Tracks of the 14-victory.md catalogue are enabled and, for each, the values of its settable parameters — together with Charter Mode, Warrant Mode, the number of Sealed Orders SO, and the Horizon H. It MUST carry a localizable name and description and is sim-affecting.

no-test-needed: no reader exists for victory Terms preset documents — every game today runs on 14-victory.md's own default parameters as resolved by packages/core/src/victory/setup.ts, with nothing settable from content (verified absence behind this note). It becomes testable when the format lands per MOD-1080: a preset names which Tracks of VC-880's catalogue are enabled plus each one's settable values together with Charter Mode, Warrant Mode, SO and H, carries a localizable name and description, and is sim-affecting in the sim hash.

MOD-1080 A Terms preset, and a scenario (MOD-770), MUST be able to set every parameter 14-victory.md declares settable, within the ranges it declares — which is the first half of the obligation VC-3200 places on this document — and the format MUST express all of them without exception, including Vigils, Ebb floors, thresholds, RotP, A, WarPeriod, and per-Track enable flags.

no-test-needed: no Terms preset or scenario can exist yet — there are readers for neither form (the absence of scenarios is asserted in packages/client/test/tutorial.test.ts), so every-settable-parameter-expressible has no subject. What it would wrap already stands: resolveVictorySetup at packages/core/src/victory/setup.ts resolves VC-470's parameters and rejects an out-of-range one by name rather than clamping. It becomes testable when the formats land per MOD-1070/MOD-770: every parameter 14-victory.md declares settable appears without exception — Vigils, Ebb floors, thresholds, RotP, A, WarPeriod and per-Track enable flags included — completing this row's half of VC-3200.

MOD-1090 A Terms preset, a scenario, and every other form of content MUST NOT be able to: add a Track to the catalogue of VC-880; disable Last Standing (VC-450); alter a Standing weight; alter a tie chain; alter the Ebb formula; or change any reward in VC-1860's exhaustive list. This is the second half of VC-3200 and the format MUST make each of them inexpressible rather than merely rejected — there is no key for a Standing weight in the schema at all.

Rationale, quoting the obligation's own reason: parameters are content, the scoring model is rules. A mod that changes what Standing counts changes what every tie-break in the game means, and no readout, estimate or lint rule would still be true (VC-3200). The stronger form — no key rather than a rejected key — is chosen because a rejected key invites an author to ask for it to be accepted, whereas an absent one states the design.

no-test-needed: nothing exists that could add a Track or alter the scoring model — no content document of any kind can be read in-tree yet (verified absence behind this note), so the prohibition binds nothing today. Its point is structural, and testable when formats land per MOD-1070/MOD-1080: there is no key for a Standing weight anywhere in the schema at all, Last Standing cannot be disabled under VC-450, tie chains and the Ebb formula are unexpressible, and VC-1860's rewards have no slot — inexpressible rather than rejected.

MOD-1100 Every Terms preset MUST pass 14-victory.md's setup linter (VC §12.4) before it may be published, and the publish flow MUST show the generated Terms paragraph (VC §12.3) and the estimated game length. A preset failing linter check L4 — the termination proof of VC-090, or the event-cadence requirement of VC-060 — MUST be rejected, not warned about.

Rationale: VC-090 requires every legal setup to be provably finite and makes an unbounded one a linter error. A workshop that let an unbounded Terms preset be published would be distributing a game that cannot end, and the person who downloads it finds out at turn 400.

no-test-needed: no publish flow exists that could gate on a linter, and nothing reads a Terms preset to lint (verified absence behind this note). The discipline it would gate already stands for anything reaching game setup: resolveVictorySetup at packages/core/src/victory/setup.ts rejects an out-of-range victory parameter with the named VC-470 rejection rather than clamping. It becomes testable when the workshop lands per MOD-830: a preset failing linter check L4 — the termination proof of VC-090 or event cadence under VC-060 — is refused before it can be published, and the publish flow shows the generated Terms paragraph with an estimated game length.

MOD-1110 The subjects of Charters, Sealed Orders and Warrants — which Region, which Landmark, which rival — are drawn at generation from the generated map per VC-170 and are not content. A scenario MAY fix a Charter per seat from the pool of VC-1630 (MOD-780) and MAY fix the Sealed Order count, but MUST NOT author a new Charter, a new Sealed Order text, or a new Warrant form: all three pools are enumerated in 14-victory.md (VC-1630, VC-1750, VC-1810) and are closed for the same reason the Track catalogue is.

no-test-needed: no scenarios exist in which Charter subjects could be fixed — nothing reads seat-fixed Charters or Sealed Order counts today (verified absence behind this note); T12's Orders draw against the generated map per VC-170 exactly as this row describes, through packages/core/src/victory/charters.ts. It becomes testable when scenarios land per MOD-780: a scenario may fix one Charter per seat from VC-1630's pool and fix the Sealed Order count, but authoring anything outside VC-1630, VC-1750 or VC-1810 is refused at load.

MOD-1120 The victory plugin kind: designed, reserved, and not shipped in v1. A mod that adds a genuinely new victory condition is a compelling capability and this document specifies its shape so that admitting it later is a decision rather than a redesign. It is not loadable in v1, and it MUST NOT be loadable until 14-victory.md amends VC-880 and VC-3200 to admit it.

Its designed shape, should it be admitted:

The reasons it is not shipped in v1 are worth stating rather than leaving as an omission. First, VC-3200 forbids it today and this document does not get to overrule its owner. Second, VC-030 requires every enabled Track to be answerable in integers at every Seal for the readout, the setup estimator, the AI evaluator and the setup linter — and a plugin-supplied Track defeats the estimator and the linter specifically, because VC-090's termination proof and VC-2030's length estimate are computed from the catalogue and cannot be computed for an arbitrary function. Admitting the kind therefore requires an answer to "what does the setup screen tell a player about how long this game will take", and "we cannot say" is a real answer but it is 14-victory.md's to give. Third, the natural first request — "first to build a Carrier", "hold three Arsenals" — is already on the table as 02-units-and-industry.md US-2180, and if 14 admits unit- and Industry-valued terms into the existing catalogue then most of the demand for this kind evaporates without any plugin at all.

MOD-1230 records the request; Open question 2 records what must be decided.

no-test-needed: no plugin host exists that could load or refuse a victory kind — nothing instantiates any module in this tree (verified absence behind this note), so the reservation has no enforcement point yet. It becomes testable when section 8 lands per MOD-570 and MOD-610: a victory-kind module is refused with a clear message while VC-880 and VC-3200 stand as written, and admitting it later stays an amendment by 14-victory.md (recorded for MOD-1230) rather than a host-side decision.


12. Contracts on other documents

Obligations this specification places on subsystems it does not own, recorded so that a change on either side is detectable.

MOD-1200 02-units-and-industry.md owns the unit definition schema and this document's §3 is its serialization. Where the two disagree, 02 wins. Specifically: §3 assumes US-070's identity fields are the complete identity set with no compatibility alias; US-720's rejection of unknown fields with no exemption; US-230's eight combat integers with no per-target table; US-120's three terrain-related fields with no per-unit cost map; and US-470's eighteen flags as a closed set.

no-test-needed: a contract between documents rather than code — section 3's assumptions (US-070's identity set complete with no compatibility alias, US-720's unknown-field rejection without exemption, US-230's eight combat integers, US-120's three terrain fields, US-470's closed flag set) stand as written against packages/core/src/units/schema.ts today, and nothing in the tree could silently disagree: a schema change lands as a code change under 02's own tests. The repeatable check is reading plus verify-time citation resolution (scripts/check-spec-refs.mjs), which keeps every named identifier live on both sides.

MOD-1210 02-units-and-industry.md MUST state whether the Industry Works multipliers and upgrade costs of US-820 are settable by a rule preset and, if so, their legal ranges. Until it does, MOD-235 keeps them out of the format entirely. Its own open question 3 says this is the decision most likely to be reversed, which is precisely why a modding surface should not be opened onto it first.

no-test-needed: a ruling owed by 02-units-and-industry.md, not a code obligation — until it states whether US-820's Industry Works multipliers and upgrade costs are settable (and their ranges if so), MOD-235 keeps them out of the rule preset format entirely, which is exactly what holds today since no preset document can be read at all. It becomes checkable when 02 answers: a text change moves this row's boundary, and verify-time citation resolution flags any drift in between.

MOD-1220 02-units-and-industry.md MUST decide where a custom set's counter relationships live. US-1630 requires both lists on every unit card and US-2110 requires the encyclopedia to be generated from the loaded set, but US-1640 states the shipped web as prose in that document and §2's schema carries no field for it. MOD-295 proposes a presentation-only companion document as the answer, which keeps US-720's closed schema intact; if 02 prefers fields on the unit record instead, MOD-295 is withdrawn and MOD-225 follows.

no-test-needed: a decision owed by 02-units-and-industry.md with no code subject on either side — US-1630 requires both counter lists on every unit card and US-2110 requires the encyclopedia to be generated from the loaded set, while the shipped web exists only as prose (US-1640) with no field for it in section 2's schema; MOD-295's presentation-only companion document is this tree's working answer. It becomes testable when authoring lands whichever way 02 rules: either the companion document loads and validates, or fields on the unit record do — not both, per MOD-295 with MOD-225 following.

MOD-1230 14-victory.md VC-3200's first half is satisfied by MOD-1080 and its second half by MOD-1090. Beyond that, 14-victory.md MUST decide whether a victory plugin kind (MOD-1120) may ever be admitted, and if so what VC-090's termination proof and VC-2030's length estimate say about a Track whose progress function the engine cannot analyse. It MUST also resolve 02-units-and-industry.md US-2180 — whether Industry level, unit classes built, or possession of specific unit types are legal Track terms — because a positive answer there removes most of the motivation for the plugin kind.

no-test-needed: a decision owed by 14-victory.md (with 02's US-2180 feeding it) that no code in this tree can make or test — the victory plugin kind stays designed and unloadable while VC-880 and VC-3200 stand as written. It becomes checkable when 14 answers: a text ruling on what VC-090's termination proof and VC-2030's length estimate owe an unanalysable progress function, with verify-time citation resolution keeping MOD-1120 consistent with it in the meantime.

MOD-1240 13-command.md MUST confirm that a Doctrine library (MOD-1040) is a legitimate content type and that importing one into a running game is the explicit, counted action CM-800 requires. Its §6 currently describes Doctrines as created in-game only, and says nothing about a shared, versioned, distributable set of them; nothing in CM-770 through CM-830 forbids it, and MOD-1030 states the boundary this document believes CM-330, CM-570 and CM-2060 draw. If that reading is wrong, the error is here.

no-test-needed: a confirmation owed by 13-command.md — its section 6 still describes Doctrines as created in-game only while nothing in CM-770 through CM-830 forbids a shared, versioned, distributable library and MOD-1030/1040 state the boundary this document believes. No executable pin is possible: the subject is another document's text, and what keeps it repeatable is that both sides name each other by identifier, which verify-time citation resolution (scripts/check-spec-refs.mjs) enforces on every run.

MOD-1250 01-game-rules.md owns terrain and this document's §4 is its serialization. GR-370's validation is authoritative and MOD-305 adds only format-level checks. Where GR-1700 asks 02 for a recon flag, MOD-240's table follows 02's answer and does not anticipate it. GR-420's three Installations and GR-450's three edge features are the complete palette of MOD-710.

no-test-needed: a statement of ownership rather than a check — section 4 stands as the serialization of 01's terrain rules and nothing in-tree could disagree with it without landing as a code change; packages/core/src/world/terrain.ts already carries GR-370's eleven base types with the five movement classes exactly as that document declares. It becomes testable when custom sets load per section 4: MOD-305's format-level checks run alongside GR-370's authoritative validation, and MOD-710's palette follows 01 if it adds an object rather than anticipating one.

MOD-1260 01-game-rules.md MUST state whether a custom terrain set may declare a number of terrain types other than eleven. GR-370 caps the count at 256 and GR-260 says the base set is exactly eleven, which reads as "custom sets may differ" but is not said. MOD-301 assumes they may; every downstream rule this document could find — GR-290's table, GR-350's attributes, GR-1520's mixes — is already written per-type rather than per-eleven, so the assumption looks safe and is recorded here so that it is checked rather than inherited.

no-test-needed: a question recorded for 01-game-rules.md — until it states whether a custom terrain set may declare other than eleven types (GR-370 caps the count at 256, GR-260 says the base set is exactly eleven), MOD-301 assumes they may. Nothing in this tree constrains or tests that assumption today because no custom terrain set can be loaded — world data rides BASE_TERRAINS from packages/core/src/world/terrain.ts. It becomes checkable when 01 answers: a text ruling, with every downstream rule it found (GR-290's table, GR-350's attributes, GR-1520's mixes) already written per-type rather than per-eleven.

MOD-1270 10-turn-model.md TM-200 and TM-210 are the transport MOD-660 requires of an AI plugin: order records into the authoritative log, one submission per seat per turn, during that seat's Orders phase. TM-340's stable board and TM-290's well-defined empty turn are what make a per-turn plugin invocation and a faulted-plugin fallback correct with no special case.

no-test-needed: a contract statement confirming what core already gives section 8 — TM-200/TM-210's order records into the authoritative log and one submission per seat during its Orders phase, with TM-340's stable board and TM-290's well-defined empty turn standing in 10-turn-model.md's implementation today; no new code is required by this row. What it does is make a change on either side detectable: the identifiers are named on both sides of the contract, so verify-time citation resolution (scripts/check-spec-refs.mjs) keeps them honest while the plugin host awaits section 8.

MOD-1280 03-architecture.md owns canonical JSON serialization (used by MOD-140), the plugin host (AR-855), the PRNG streams (AR-350, AR-370) and the worldgen entropy rule (AR-400). This document cites AR-750 only for its deterministic tie-break.

Discharged, and recorded rather than deleted so that the check is repeatable. This requirement previously held that AR-750's pathfinding rule and AR-430, AR-440, AR-450, AR-460 and AR-470's fog rules were still written against the withdrawn content model and needed re-resolving. They have been: AR-430 now reads the Terrain Record and the knowledge levels of 01-game-rules.md GR-1140, GR-1150, GR-1160 and GR-1630; AR-440 reads vision, visionAir and the detection flags of 02-units-and-industry.md US-120 and §3.4 and forbids identity branching per US-060; AR-450 reads the Contact and fidelity of GR-1210 and GR-1260, including the non-monotone re-observation of GR-1240; AR-460 and AR-470 read GR-1180, US-590 and US-600. AR-750 reads the cost table of GR-290 indexed by US-120's terrainClass. The obligation is closed.

no-test-needed: discharged and recorded rather than deleted so the check stays repeatable — its own paragraph lists the re-resolved rules (the fog stack of AR-430 through AR-470 now reading GR-1140 through GR-1630's knowledge levels, AR-750's cost table indexed by US-120's terrainClass), and the residual duty is text-level against 03-architecture.md. No executable pin exists on a closed prose obligation; what remains mechanical is verify-time citation resolution (scripts/check-spec-refs.mjs) keeping every cited identifier live on both sides.

MOD-1290 06-ai.md MUST source the plugin container, sandbox, signing, transport (MOD-660), SDK language roster (MOD-670(a)) and fuel-metering transform (MOD-610, MOD-615) from this document, and MUST NOT restate them. Its roster of shipped AI personas, its handicap levers and its view surface are its own and this document takes no position on them.

Discharged, and recorded rather than deleted so that the check is repeatable. This requirement previously carried three findings against 06-ai.md and all three are resolved. (1) Its two citations of the retired MOD-980 are gone: AI-265 now cites MOD-670(a) for the language roster, and the float-discipline text of its §5 now cites MOD-600. No citation of that retired identifier remains anywhere in 06-ai.md. (2) AI-230 no longer describes an AI that submits an order and observes its resolution: it now requires orders to be issued during the Orders phase against the stable board of 10-turn-model.md TM-340, forbids invocation between activations and any mid-Cascade decision point (TM-050), and routes what used to be a mid-turn decision into a conditional order evaluated at activation (TM-280); AI-180 takes the lifecycle from MOD-660. (3) AI-600 now states that a custom unit set MUST NOT carry executable code, citing 02-units-and-industry.md US-2060 and MOD-300. 06-ai.md's own contract table records the same three closures from its side. The obligation is closed.

no-test-needed: discharged and recorded rather than deleted — its paragraph enumerates the three resolved findings against 06-ai.md (AI-265 now citing MOD-670(a), AI-230 issuing orders during the Orders phase per TM-340, AI-600 forbidding executable code in a custom set under US-2060 and MOD-300). The residual duty is text-level: 06 must source section 8's machinery from this document and not restate it. No executable pin exists on a prose obligation; verify-time citation resolution (scripts/check-spec-refs.mjs) keeps the cross-citations that record it honest on every run.

MOD-1300 05-multiplayer.md MP-110 and MP-120 own content pinning and distribution enforcement; MOD-160 and MOD-870 supply the hashes and the fetch. 08-services-platform.md SVC-485 and SVC-490 delegate size caps here and MOD-885 answers them; SVC-980 and SVC-840 bound MOD-890. 04-ui-ux.md owns the presentation of the Mod Manager, both editors, the setup screen and the workshop, implementing the content requirements stated here.

Discharged: this requirement previously held that 04-ui-ux.md's dependency list still named the void 02-unit-sets-content.md. It names 02-units-and-industry.md, and the only remaining mention of the void file in that document is UX-1480's note recording that its US- citations have been re-resolved, which is the correct handling rather than a defect. The obligation is closed.

no-test-needed: discharged — its only live finding (04-ui-ux.md's dependency list naming the void 02-unit-sets-content.md) is recorded as corrected in the paragraph itself, leaving text-level duties: MP-110/MP-120 own pinning and distribution, SVC-485/SVC-490 delegate caps to MOD-885, SVC-980 and SVC-840 bound MOD-890, and 04-ui-ux.md owns presentation of every surface this document specifies. No executable pin exists on another document's prose; verify-time citation resolution (scripts/check-spec-refs.mjs) keeps the named obligations resolvable while each side performs it in its own code.

MOD-1305 docs/art/00-art-direction.md and docs/art/01-units.md state atlas page dimensions against MOD-505 and MUST follow its revision. ART-320 requires pages to be power-of-two square and cites MOD-505 as the authority for it; ART-1030 pads canvases to those sizes and gives the reason MOD-505 now carries — the WebGL1 fallback path of 04-ui-ux.md UX-385. Under MOD-505 as revised, power-of-two padding is required only for a page needing mipmapping or non-clamp wrapping on that fallback tier, and block alignment is the primary-path rule; both requirements need restating. docs/art/03-ui-effects.md, which already records power-of-two as not required with a 2048 maximum dimension for its own atlases, becomes consistent with the other two rather than contradicting them. If UX-385's WebGL1 tier is ever retired, the padding retires with it in all three documents without further archaeology, which is the property this split exists to buy.

no-test-needed: an amendment owed to two art documents, not a code obligation — docs/art/00-art-direction.md ART-320 and 01-units.md ART-1030 still state power-of-two page padding that MOD-505 as revised requires only for the WebGL1 fallback tier of UX-385 (block alignment is the primary-path rule), so both need restating. Nothing in-tree tests this today: the client's current tint path bakes a single-multiply page per seat and tier at packages/client/src/map/scene.ts citing ART-180, which is not an atlas rule. It becomes checkable when the art documents restate, with docs/art/03-ui-effects.md already consistent as the reference case.


Open questions

  1. Does the unit-set format need a versioned migration path? US-080 fixes id forever and MOD-190 carries a schemaVersion, but nothing says what happens when 02 adds a nineteenth flag or a new capability parameter and a hundred workshop sets predate it. The cheap answer is that a missing field defaults and the set's engineApi range keeps it out of incompatible clients; the honest answer is that a defaulted flag silently changes how a set plays. A written migration contract — additive fields default, removed fields are a major schemaVersion bump, renamed fields never happen — probably belongs in this document and is not yet written.

  2. The victory plugin kind. MOD-1120 designs it and gates it. What has to be settled, in order: whether 14-victory.md will amend VC-880 and VC-3200 at all; if so, what the setup screen tells a player about the length of a game whose Track the estimator cannot analyse (VC-2030) and what the termination proof of VC-090 says about it; whether admitting unit- and Industry-valued terms into the existing catalogue (US-2180) removes most of the demand first; and whether a Modded-only, ranked-excluded capability is worth the ABI surface it costs.

  3. Where does a scenario's authored victory state live relative to the Ebb? A scenario may fix a Track's threshold (MOD-1080) and 14-victory.md's Ebb then descends it from that value (VC §7). A designer who set a threshold as a floor rather than a start will find it drifting. Either the Terms format needs an explicit "no Ebb" flag per Track — which VC-1050 already implies for Hegemony at its floor — or scenario thresholds need a different semantic from preset ones. Raised, not adjudicated.

  4. Does a terrain set need a canonical presentation ordering? MOD-301 stores terrain types as an ordered array and MOD-302 gives each a stable id. The palette, the encyclopedia and the legend all need an order, and array order is the obvious one — but it means reordering a document for readability changes the sim hash. A separate displayOrder integer would decouple them at the cost of one more field nobody thinks about. Same question applies to unit sets, where US-050 already fixes iteration order as ascending id but says nothing about display.

  5. Is one unit set per game the right restriction for content? US-2080 rejects mixing sets at setup and this document follows it without argument. It is almost certainly right for balance, and it also means a "just this one extra unit" mod is impossible — an author must fork the whole shipped set, and then never receives a balance fix. A declarative overlay format (add these units, override these fields, against a named base set at a named version) would solve it, is a real amount of specification, and would need US-2080's blessing and a merge rule that survives hashing.

  6. Publication size-cap defaults. MOD-885's numbers are engineering estimates. Re-derive the graphics, audio and total-archive caps from a real stock-scale asset pack, and confirm with 08-services-platform.md that SVC-490's publisher quota composes sensibly with them. The data-format caps moved down when the combat matrix and per-unit terrain maps were deleted and should be re-checked against a real twenty-unit set rather than against arithmetic.

  7. How far the no-floating-point rule reaches into a plugin. 06-ai.md AI-385 binds plugin code whose output influences game state to 03-architecture.md AR-080's no-floating-point-arithmetic rule, while MOD-600 permits plugin-internal float arithmetic and keeps only the ABI boundary float-free. Decide, before the plugin validator is written, whether the validator rejects float instructions outright — which would exclude AssemblyScript, whose standard library is float-bearing, and therefore a first-class template of MOD-670(a) — or polices only the boundary MOD-600 fixes.

  8. Audio codec baseline. MOD-470 needs a confirmed decode matrix across the supported browser versions to fix the one variant the pipeline always emits. Until then a stock sound could be silent on a real player's device.

  9. GPU-compressed textures. MOD-410 leaves atlas encoding to the install-time pipeline. Measure KTX2/Basis against a stock-scale pack: does the texture-memory saving on low-end mobile justify transcoding inside a browser tab at install time, and does the per-device format matrix leave a fallback still worth shipping?

  10. Plugin module size and a genuine-TypeScript guest. MOD-885's 32 MiB per-module cap is satisfiable by the MOD-670(a) roster as written. If a template carrying a JavaScript engine inside the guest is ever admitted, re-derive the cap against a real build of it — and re-check it against the 256 MiB per-entry and 2 GiB archive caps — before that template is promised anywhere.

  11. The metering instrumentation pass. MOD-610 turns fuel from a runtime setting into a compiler pass this project owns. Who owns it, what a unit of budget costs per instruction class, how its version is recorded alongside 06-ai.md AI-410's fuel limits, whether the transform runs at publication or at install, and how a game recorded under the uninstrumented local-interactive path is marked so it can never be mistaken for verification evidence later.

  12. Browser storage budget and the offline shell. MOD-115: how much stock content must a browser client hold before it can start its first game, is mid-game eviction always recoverable, and does the answer force a streamed per-asset delivery mode rather than whole-package install? MOD-900 additionally requires a service-worker-cached application shell whose update policy must compose with MOD-125's immutable content URLs and with client-release-pinned certified hashes (MOD-060).

  13. Certified-content updates. When a shipped unit-set or terrain-set value needs a fix, what re-certification process keeps MOD-170's Certified label coherent across client versions, and what happens to an in-progress game pinned to the old hash? With 03-architecture.md and 08-services-platform.md.

  14. Collaborative editing. Multi-user cloud editing of maps and scenarios is deliberately out of scope for v1; revisit after the workshop ships.

  15. Post-v1 workshop federation. MOD-890 defers cross-instance mirroring to SVC-980's federation revisit. When that opens, this document owes the content half: licence propagation across instances, whether a mirrored item keeps its origin (id, version, contentHash), and how sim-hash verification (MOD-160) behaves for content fetched from a mirror.

  16. Canonical lowering versus a component-native host. MOD-580 fixes a core-module lowering because browsers cannot instantiate components. If a server-side host later adopts a component-native runtime for convenience, the two paths could diverge in exactly the way MOD-575 forbids. Decide whether that door stays closed permanently or reopens only behind the MOD-600 differential job.

  17. The worldgen half of the in-worker host. MOD-575 places the mapgen draw cursor in the plugin worker for the duration of a generate call, because 03-architecture.md AR-400(a)'s draw functions are synchronous imports. Unlike the AI case, which 06-ai.md AI-235 specifies end to end, no partner requirement states the worldgen arrangement: confirm with 03-architecture.md that a cursor lent to a worker and returned advanced still satisfies AR-370's host-owned-stream rule, decide what happens to the cursor when a generation is cancelled (MOD-650) or the worker is terminated (MOD-620), and settle whether 03 or this document owns that statement.


Built from source 47fa2827eff0 · VERSION.json