Invariants¶
gorge rests on a short list of design rules. This page states each one, why it exists, what goes red when you break it (by exact test name), the legitimate way to change it, and the mistake people actually make. It is for anyone changing engine, server or bot code: read it before your first non-trivial change, and come back when a ratchet or golden test fails and you want to know what it is protecting.
Every test named here was checked to exist, and the ones in the summary run
green, on develop @ 513a2a0c3, 2026-10-21.
Where a rule is held only by convention (nothing mechanical catches you at the point of writing), the page says so; the after-the-fact backstop is usually the replay and golden-head tests.
Summary¶
| # | Invariant | Enforced by |
|---|---|---|
| 1 | All state mutation goes through events.Apply |
after the fact: TestRepoDeckGamesReplayExactly, TestHeads, make sim replay verification |
| 2 | events.Kind is append-only |
TestEveryKindHasADescriptor, the golden heads and replay fixtures |
| 3 | No nondeterminism | TestTimeIsImportedOnlyByTheHost, TestNoLegacyMathRand; map order by convention |
| 4 | No cgo, no third-party dependencies | TestRootModuleHasNoRequires, TestEngineCompilesFor32Bit |
| 5 | engine/ imports nothing outside engine/ |
TestDependencyOrderHolds and the layering_* tests (named edges) |
| 6 | Never commit Forge card scripts | TestNoForgeScriptsTracked |
| 7 | The decision kinds are a closed set | TestKindsListsEveryKindOnce |
| 8 | Seats see only their view; the server trusts no client | TestNoExportLeaksAnEngineGame, the engine/view redaction tests, decision.Validate's tests, the httpapi claim tests |
| 9 | Ratchets only shrink | TestEveryRepoDeckIsFullySupported, TestEveryRepoDeckParamsAreRead, TestKnownApproximationsOnlyShrinks, … |
| 10 | Golden chain heads move only with a named cause | TestHeads |
| 11 | Every test fits the budget | cmd/testbudget in the post-merge suite |
| 12 | Corpus or it did not run | CI refuses to test without .cards/; locally, convention |
Two companion documents record behaviour that is not an invariant
violation even though it can look like one: the
engine contracts
(deliberate behaviour, such as the deterministic fallback every asking
primitive uses when its host cannot answer) and AGENTS.md's Known
approximations table (real, shrinking debt).
All state mutation goes through events.Apply¶
Rule. Nothing outside engine/events writes a state.Game field. Engine
code proposes an event: e.emit(events.Event{...}) in engine/rules,
h.Emit(...) (the effects.HostEmit methods) in engine/effects. The
event is appended to the log and then folded into state by events.Apply.
flowchart LR
code["rules / effects code"] -->|"e.emit · h.Emit"| repl["replacement effects<br/>(may substitute)"]
repl --> log["Log.Append<br/>seq + SHA-256 chain"]
log --> apply["events.Apply<br/>the only writer"]
apply --> game["state.Game"]
apply --> trig["trigger check"]
replay["replay / resume"] -->|"logged events"| apply
Why. A match is then completely described by its Config plus its event
log. Replaying a log, resuming a server after a restart, undo, search clones
and bug reproduction are all the same operation: feed the logged events back
through events.Apply. Going through emit also gives replacement effects
the chance to modify the event and lets triggers see it. A direct write is
invisible to all of that: the live game and its replay silently disagree.
Enforced by. After the fact, not at the line you write:
TestRepoDeckGamesReplayExactly (engine/rules/acceptance_test.go) replays
full games from their logs and compares event for event; make sim does the
same for 20 four-seat games (mtgsim -verify); TestHeads pins the chain
heads; engine/replay names the first divergent event
(replay.FirstDivergence).
To change. Never. If you need new state, add an event kind
(invariant 2) and its events.Apply case.
Classic mistake. A rules helper sets obj.Tapped = true or bumps a
counter field directly "because it is only bookkeeping". Every test passes
today; the first replay of a game that reaches that line diverges. Test
fixtures may set state directly to build a position (e.G.AddObject,
e.G.SetZone); engine code may not.
events.Kind is append-only¶
Rule. A new event kind goes after the last constant in
engine/events/event.go,
never between existing ones, and gets one entry in kindInfo
(engine/events/kindinfo.go: its name, trigger-interest class and optional
transcript template).
Why. events.Kind is a uint8 assigned by iota, and the ordinal is part
of every event's hash. Inserting a kind renumbers every kind after it, which
moves the hash chain of every game and invalidates every recorded log:
golden heads, replay fixtures, saved server matches and feedback reports.
Enforced by. TestEveryKindHasADescriptor (engine/events/kindinfo_test.go)
fails when a kind has no kindInfo entry; TestTriggerEventInterestMapping
and its neighbours in engine/rules/trigger_eligibility_test.go check the
trigger classes. An insertion shows up as moved TestHeads goldens and a
DIVERGED replay of the committed cmd/repro fixtures.
To change. Append. Removing or reordering kinds is not done.
Classic mistake. Inserting FooTapped next to Tap "where it belongs".
Put it at the end and say in its comment what it relates to; the existing
comments in event.go show the form.
No nondeterminism¶
Rule. The engine is a pure function of (Config, intents):
- No wall clock. Only the packages in the
allowedmap ofTestTimeIsImportedOnlyByTheHost(internal/archtest/arch_test.go) may importtime: the server (server/host,server/host/httpapi,server/host/manabrewhttp,server/gorged) for its injected sleep, SSE keep-alive and deadlines, and three measurement commands (cmd/cardfuzz,cmd/paymirror,cmd/enginebench) that time only their own runs. Library code that needs timing takes an injected hook. - No ambient randomness. The engine has one seeded PCG source with a draw
counter (
engine/rules/rng.go,math/rand/v2); a bot's generator is seeded from the match seed and its seat. - No
maprange whose order can reach an event, a decision's option order, a trace or a checkpoint. Sort the keys, or use a dense slice or a bitset. - Ties break by lowest option index.
Why. Byte-identical replay, the golden heads, reproducible bug reports, reproducible benchmarks and reproducible training all depend on it. Go randomises map iteration on purpose, so a map range on the event path is nondeterministic even within one binary.
Enforced by. TestTimeIsImportedOnlyByTheHost, TestNoLegacyMathRand
(no math/rand v1 anywhere), TestPayStaysDeterministic (the payment layer
imports no clock, randomness, sync, unsafe or os) and
TestPayMirrorClockStaysAtCommandBoundary. Map order is convention: the
backstop is TestHeads, the replay tests and make sim, which catch it only
when the order happens to differ.
To change. To let a package import time, add it to the allowed map
with a paragraph in that test's doc comment arguing that no game, event,
replay or verdict can read the clock.
Classic mistake. Building a decision's option list by ranging over a
map[ObjID]bool. It passes locally, then a TestHeads run moves on one
machine and not another. That flakiness is the signature: suspect a map.
No cgo, no third-party dependencies¶
Rule. The root go.mod has no require lines and go.sum is empty or
absent. The card pipeline, the rules core and the learned-policy stack are
pure Go, and the engine builds for 32-bit targets.
Why. Anyone can build gorge with only a Go toolchain: no C compiler, no
dependency audit, no supply chain. It is also what makes the
repository boundary
structural: a module with no require cannot import a companion repository.
Enforced by. TestRootModuleHasNoRequires
(internal/archtest/gomod_test.go), TestEngineCompilesFor32Bit
(internal/archtest/arch_test.go), and CGO_ENABLED=0 on every make
build.
To change. An experiment that genuinely needs a third-party module lives
in its own module that imports gorge, never the other way round. (AGENTS.md
records one planned exception, wazero, for a future plugin tier.)
Classic mistake. It happened: a GPU game pool added
github.com/ebitengine/purego to go.mod on 2026-10-08. It moved out of
gorge, and the test above was added so it cannot recur.
engine/ imports nothing outside engine/¶
Rule. Non-test code under engine/ imports only the standard library
and other engine/ packages. Within and around it, a set of named edges must
never appear, directly or transitively. The main ones:
| Never | Because |
|---|---|
engine/effects → engine/rules |
effects reach the engine only through effects.Host, so the effect library stays independent of the engine that hosts it |
engine/view → engine/rules, server/protocol → engine/rules |
nothing a client receives can carry rules knowledge |
engine/cards → engine/state; engine/deck → engine/rules |
the compiler and the deck reader sit below the game |
bots/botpolicy → engine/view, engine/rules, engine/seat |
the one policy reads a Board, never an engine |
bots/… → server/host, testkit/testutil |
bots are injected into hosts, not the reverse |
server/host, server/host/httpapi, server/gorged → testkit/testutil |
test scaffolding stays out of the server |
Inside engine/rules, each sub-package (cost, chars, combat,
trigmatch, pay, resolve, scriptfacts) has its own pinned import set
and reads the engine through a narrow Board interface whose method count
only shrinks.
Why. The engine stays testable and embeddable without the server or any
bot; a client can never be handed engine internals; and the layering inside
engine/rules keeps the biggest package from becoming one tangle.
Enforced by. TestDependencyOrderHolds (internal/archtest/arch_test.go)
for the named edges; TestCostVocabularyIsALeaf,
TestCombatImportsStayBelowRules, TestTrigmatchImportsStayBelowL5,
TestScriptFactsImportsStayBelowRules, TestCharsImportsStayBelowRules,
TestResolveImportsStayBelowRules, TestPayImportsStayBelowRules and the
*BoardOnlyShrinks tests for the sub-packages. The group rule itself
(engine/ → nothing outside) is measured, not yet one test:
go list -deps ./engine/... showed no gorge package outside engine/ and no
non-standard-library package on 2026-10-21. cmd/pkgmap draws any tier
back-edge in red on Packages.
To change. Adding a forbidden edge is welcome. Removing one needs a design discussion first.
Classic mistake. An effect or a view needs one engine helper. Go itself
rejects effects → rules as an import cycle, so the helper gets moved into
a small shared package, which then grows a dependency on rules.
TestDependencyOrderHolds checks transitive edges and catches it. Extend
effects.Host with a narrow, named capability, or move the logic into
rules.
Never commit Forge card scripts¶
Rule. Forge's card and token scripts are GPL-3.0 and gorge is
Apache-2.0. They live only in the gitignored .cards/ and are never
committed: not as files, not embedded in a fixture, not pasted into a test, a
doc or an issue.
Why. Licence compatibility. gorge ships a compiler for the format and
fetches the corpus at a pinned commit (FORGE_REF), so nothing GPL-licensed
is distributed with it.
Enforced by. TestNoForgeScriptsTracked
(engine/cards/boundary_test.go), which checks the working tree, the index
and HEAD and recognises a script by content (a Name: first line and an
Oracle: line), not by path.
To change. Not negotiable. A script that is wrong is fixed on the fork
(adams-shaun/forge, branch gorge)
and sent upstream; gorge picks it up when FORGE_REF moves.
Classic mistake. Copying a card's script into a test so the test does not
need the corpus. Name the card and load it through
testutil.CorpusRegistry(t), or write a minimal synthetic script of your own
(card(t, "Name:Mountain\nTypes:Basic Land Mountain\nOracle:x\n")). Replay
fixtures strip token-script text into the gitignored
cmd/repro/testdata/.tokens/ for the same reason.
The decision kinds are a closed set¶
Rule. When the engine needs a player's input it poses a
decision.Decision of one of exactly thirteen kinds (decision.Kinds in
engine/decision/decision.go): priority, target, attackers,
blockers, mulligan, modes, trigger_order, trigger_optional,
commander_zone, choose, replacement, arrange, starting_player.
Conceding is an option on every priority decision, not a kind.
Why. Every seat (bots, the web client, the wire protocol, the ManaBrew and SpellBench adapters, gorge-compliance's scenario runner) must be able to answer every kind. A new kind is a breaking change for all of them at once.
Enforced by. TestKindsListsEveryKindOnce
(engine/decision/decision_test.go); the exhaustive switches in the
consumers.
To change. A maintainer decision and a design first; then every consumer
is updated in the same change. Most new choices fit an existing kind:
choose already covers numbers, names, types, yes/no and card subsets.
Classic mistake. Adding a kind for a new card's choice instead of
expressing it as a choose.
Seats see only their view; the server trusts no client¶
Rule. A seat (a bot, a test script or a person in a browser) never sees
the engine. It receives a view.View, built by engine/view for that seat,
and answers with a decision.Intent naming option indices. The engine
accepts only what it offered, and the server binds every seat credential to
one table and one seat.
- Hidden zones project as counts. Even the omniscient spectator view never exposes library order.
rules.Engine.Submitrunsdecision.Validate: the intent must match the pending decision's sequence number and player, and choose only offered options within the allowed arity. "Anything the engine did not offer is rejected, which is what keeps the client rules-ignorant."- Every seat-authorised HTTP route goes through
claimForTable(server/host/httpapi/rest.go): no token is 401, a claim for another table is 403, and a?seat=the claim does not hold is refused. - Search code that samples hidden information sees public state only, and clairvoyant measurement modes never reach a hosted table.
Why. A bot that can read hidden state produces meaningless strength numbers, and one that does so on a hosted table is cheating a real player. A server that trusts a client-sent seat or move can be driven by any browser console.
Enforced by. TestNoExportLeaksAnEngineGame (no exported server/host
or httpapi function signature mentions *state.Game or *rules.Engine);
the view redaction tests such as
TestPublicVisibilityRedactsEveryHandAndShowsEveryPublicZoneAndPool and
TestOmniscientRevealsEveryHandAndPoolNeverTheLibrary
(engine/view/visibility_pin_test.go); decision.Validate's tests
(TestValidateRejectsWrongPlayer, TestValidateRejectsWrongSequence,
TestValidateRejectsOutOfRangeAndDuplicates, …); and the claim tests in
server/host/httpapi/seat_test.go (TestTableBoundSeatClaimRejectsEveryOtherTable,
TestIntentSubmitsThroughTheClaimAndRejectsOthers).
To change. Never weaken. A new seat route goes through claimForTable;
new client-visible state goes through engine/view.
Classic mistake. A route that takes the seat from the query string or the request body instead of the credential. An earlier claim format that did not name its table carried cross-table privilege; such claims are now refused outright rather than grandfathered.
Ratchets only shrink¶
Rule. Several committed tables record known gaps, and each fails in both directions: a new gap fails, and a listed gap that the build has closed is stale and fails too. A branch deletes entries; it never adds one or raises a ceiling.
| Table | Test | Today |
|---|---|---|
engine/rules/testdata/known-unsupported/ (one file per repo-deck card the build cannot fully play) |
TestEveryRepoDeckIsFullySupported |
2 of 1,123 cards |
engine/rules/testdata/paramcensus/ (cards with a parameter no code reads) |
TestEveryRepoDeckParamsAreRead |
16 of 1,123 cards |
knownUnmodelledCountHeads |
TestEveryRepoDeckCountHeadResolves (engine/rules/count_head_ratchet_test.go) |
|
effects.modelledValueHeads vs the evaluator |
TestValueHeadRegistryMatchesEvaluator |
|
AGENTS.md's Known approximations table |
TestKnownApproximationsOnlyShrinks (testkit/testutil/agentsdoc_test.go) |
8 rows, none over 600 bytes |
| function-length and interface-width ceilings | TestCodeShapeOnlyShrinks, TestLongFunctionsOnlyShrink (internal/codeshape) |
skipped by the maintainers since 2026-10-06; the direction still holds |
| test-budget exception lists | cmd/testbudget |
see invariant 11 |
"Today" from go test -v -run 'TestEveryRepoDeckIsFullySupported|TestEveryRepoDeckParamsAreRead' ./engine/rules/
and agentsdoc_test.go's knownApproximationRows, 2026-10-21 at 513a2a0c3.
Why. "Supported" should only ever mean more. A two-way ratchet makes the table the exact truth: it cannot silently understate a regression, and it cannot keep claiming a gap that is gone.
Enforced by. The tests above, plus review.
To change. When your change closes a gap, delete its entry in the same
commit. The Known-approximations table is a closing register: a landing
change deletes its row and lowers knownApproximationRows; a deviation it
cannot close goes in the commit message, never in a new row.
Classic mistake. Adding a known-unsupported file to turn a red test
green, or fixing a card and leaving its file behind (which also turns the
test red).
Golden chain heads move only with a named cause¶
Rule. TestHeads (engine/rules/heads_test.go) plays one deterministic
bot game per seat count (2, 4, 6 and 8 seats, over the 12 Legacy repo decks)
and compares each game's final hash-chain head with
engine/rules/testdata/heads/<seats>.txt. A head may move only when the
change was meant to alter what those games do, and the commit message names
the first diverging event and its cause.
Why. The chain head is a 16-hex-digit fingerprint of everything that happened in a game. If it moved, behaviour changed. The goldens turn "did this refactor change anything?" into a yes/no answer.
Enforced by. TestHeads (also run as its own CI step).
To change. Re-pin with
GORGE_UPDATE_HEADS=1 go test ./engine/rules/ -run '^TestHeads$', then find
the first diverging event with cmd/headdiff (-dump on the base, -against
on your branch; go doc ./cmd/headdiff has the recipe) and put it in the
commit message, not in a file.
Classic mistake. Re-pinning because "the heads always move". An unexplained moved head is a bug, often nondeterminism (invariant 3).
Every test fits the budget¶
Rule. Every single test fits 4 GB of RSS, 4 vCPUs and 1 minute of wall time. One that does not is a defect: split it into chunk tests that share fixtures; never trim its coverage.
Why. The full suite (over 12,000 top-level Test functions on 2026-10-21) runs on shared machines after
every merge. One unbounded test can stall or kill everything else running
there.
Enforced by. cmd/testbudget, wired into the post-merge full suite: it
measures each test's wall time and each test binary's peak RSS, and fails on
any offender not already listed in the shrink-only
testkit/testutil/testdata/wall_exceptions.txt / rss_exceptions.txt.
To change. The limits are the maintainers' call. A branch never adds an exception row.
Classic mistake. Loading the corpus with cards.LoadRegistry in a test
(hundreds of MB per fresh registry) instead of the shared
testutil.CorpusRegistry(t); or running a known-heavy test whole "for a
baseline". How to run tests under the cap is on Testing.
Corpus or it did not run¶
Rule. Corpus-dependent tests load cards through
testutil.CorpusRegistry(t), which skips when .cards/ is absent. A
run without the corpus has tested very little and must not be reported
green.
Why. Skipping keeps go test ./... usable on a fresh clone with no
network. The cost is a trap: a package that prints ok in a couple of
milliseconds may have run nothing.
Enforced by. CI refuses to test without .cards/ at FORGE_REF (the
"Refuse to run without the corpus" step of ci.yml). Locally it is
convention: run with -v and look for SKIP.
Classic mistake. Testing in a fresh git worktree, which carries only
tracked files and therefore no .cards/. Link it in:
ln -s <main checkout>/.cards .cards. See
Contributing.
Changing an invariant¶
Each section's To change line is the legitimate route. For anything
beyond it (a new decision kind, a removed forbidden edge, a new dependency),
open an issue or a design document under docs/design/ first, and record a
decided behaviour that is not debt in the
engine contracts
in the same commit as the code.