Skip to content

Architecture Decision Rulings — R33

Architecture Decision Rulings — R33

Date: 2026-08-09 Scope: the cartridge load path — how a ruleset assembly reaches a room, what happens when it cannot, and what establishes that it is the assembly its publisher shipped.

Three decisions: ADR-134 (the load context’s lifecycle and the lease that owns it), ADR-135 (Archive Mode is a property of the cartridge binding, not of the room lifecycle), ADR-136 (provenance is established over a signed manifest, not over an assembly).

They are one ruling file because they are one change and each is load-bearing for the next: 134 decides what a room holds, 135 decides what a room does when it holds nothing, and 136 decides what has to be true before 134 hands anything over.

A note on what this file is not. §4.4 of the whitepaper already said that a cartridge’s AssemblyLoadContext is “created once per cartridge version per silo — never per intent and never per room — and is reference-counted for collectible unload”. None of these rulings overturns that; ADR-134 makes it concrete and narrows it in one place (when the count reaching zero actually unloads), which is why it is an ADR rather than an implementation note.


ADR-134 — A cartridge is held through a lease, and the lease is the only route to an engine

Status: Accepted Date: 2026-08-09

Context

Before this ruling, RoomGrain took IEnumerable<IRulesetEngine> from the container and built a dictionary once in its constructor. That was correct while every cartridge was compiled into the silo, and it had two properties that stopped being acceptable the moment cartridges are loaded from disk:

  1. It cannot express a release. A dictionary field has no lifetime relationship with anything. The reference count and the reference are separate facts, kept in step by nobody.
  2. Production had no engines at all. Program.cs registered none, so SubmitActionAsync answered UnknownRuleset for every action on a real silo. Only the test hosts registered CoC7eRulesetEngine, which is why the suite was green about a path that did not exist.

The failure mode this ruling is shaped around is specific and has no symptom. A collectible AssemblyLoadContext is reclaimed only when nothing references anything inside it. A host that decrements a count while still holding an IRulesetEngine gets a context that is unloaded and never collected: the rooms work, the suite passes, and the silo keeps one assembly per cartridge version per leak for the life of the process.

Decision

  1. One collectible context per (urn, version) per silo, reference-counted across rooms, as §4.4 already required. Per-room contexts are rejected — see below.
  2. A room holds a CartridgeLease, and an engine is reachable through it and nowhere else. Dispose drops the engine set before running the release, and a disposed lease throws rather than answering. Release and drop-the-reference are therefore the same act, enforced by the type rather than by review (P3).
  3. Acquisition is a saga stage, not a mailbox call. A first load reads an image, hashes it and verifies a signature — §4.4’s third forbidden class, unbounded I/O. The room takes RoomLifecycle.LoadingCartridge, dispatches, and re-enters through the front door, the shape hydration and ADR-132’s entitlement check already use. The lease is published to a field and adopted inside the mailbox; it is never a call argument, because handing an object whose type comes from another load context to Orleans’ serializer would be a second way to pin the context this design exists to release.
  4. The stage runs only where the room would otherwise have become Ready, which is what keeps ADR-132’s “a locked room whose ALC never boots” true. An owner who has not paid cannot cause this silo to read and verify a paid cartridge image.
  5. The lease is released last on deactivation, after everything the activation might still be doing with the room.
  6. The last release does not unload; a cartridge lingers unheld for Q-097 and is then swept. This is the clause that narrows §4.4.

Alternatives considered and why rejected

  • A context per room. Rejected. The context is a versioning and unload boundary and explicitly not a security boundary (§4.4, §6.4’s P0 row), so per-room isolation buys nothing against a cartridge that already runs with full host privileges — while costing a copy of every assembly, a JIT pass and a set of statics per room.

  • Unload the instant the count reaches zero. Rejected, and this is the one place this ruling departs from the obvious reading of §4.4. Orleans deactivates idle rooms routinely — idle collection, rebalance, silo migration — so zero-means-unload makes a silo re-read, re-verify and re-JIT the same cartridge continuously under ordinary load. Worse, Unload() is asynchronous: the context is unloaded when requested and collected later, so churn leaves several generations of one cartridge alive at once — which is precisely the duplicate-context waste that rejecting per-room contexts was meant to avoid, arriving by another route.

  • Handing the room a plain IReadOnlyDictionary<string, IRulesetEngine> and a separate Release() call. Rejected. It is the current shape with a second call bolted on, and it leaves the leak available: the dictionary outlives the release and nothing says so.

  • Keeping IEnumerable<IRulesetEngine> alongside the loader. Rejected as two sources of truth for one question. PreloadedCartridgeLoader exists instead — it serves container- registered engines through the same port, so a development host and a signed-directory host differ in one registration and in nothing the grain can see.

  • A background pre-warm of every catalogue cartridge at startup. Not rejected — not decided. It would make the LoadingCartridge stage always trivial, and it is a pure addition to this design. It is left out because nothing measures the first-load cost yet, and adding a warm-up whose benefit is unmeasured is how a silo acquires a startup phase nobody can justify.

Consequences

  • RoomLifecycle gains LoadingCartridge; RoomCommandStatus gains CartridgePending. Both are transient and retryable, and both are separate from the hydration and entitlement states because the three name different dependencies and an operator reading a refusal rate needs to know which one is slow.
  • Every host must register an ICartridgeLoader. There is no default and the grain requires one: a room with no loader has no rules and no reason to give for having none, which is exactly the confusion ADR-135 exists to prevent.
  • A negative, stated rather than discovered: the corpus’s own deactivation order — flush, confirm the commit, write the snapshot, release (architecture-rules §2 clause 3) — is not implemented in RoomGrain.OnDeactivateAsync. That method cancels; it does not drain. So “released after the deactivation order” currently means “released after the cancellation that ends it”. The hazard is not reachable today and that was checked rather than assumed: the snapshot path hands Room to the store, and a Room holds actors, positions and AttributeBags of SDK values — no type from a cartridge’s context crosses into it, and Room.ProposeBatch takes an IAttributeSchema per call and never stores one. The ordering is the defence that becomes load-bearing when the drain is built, not one doing work now.
  • §4.4’s “leaked-ALC detection surfaced as an operational metric” remains unbuilt. The loader logs a load and an unload and exposes a resident count; nothing yet reports a context that was unloaded and not collected. Recorded here rather than implied by the rest.
  • A surviving mutation, recorded rather than papered over. AlcCartridgeLoaderTests asserts WeakReference.IsAlive == false after a forced collection, and a mutation sweep confirmed it catches the realistic leak — a reference count that is never released. It does not pin the explicit AssemblyLoadContext.Unload() call: deleting that call leaves the suite green, because the runtime reclaims an unreferenced collectible context anyway. The call is kept for promptness rather than correctness. A test whose scope is overstated is worse than one whose scope is written down.
  • What the rest of the sweep changed, because “we mutation-tested it” is worth nothing without the result. Sixteen mutations were run across these three rulings and four survived the first pass. Two were test gaps and are now closed — an unknown publisher was never exercised, because the impostor test kept the trusted publisher’s name and swapped only its key; and the signed-urn cross-check was unreachable in a single shot, because the index and the verifier read the same file, so the case had to be built as a re-signing after indexing, which is the time-of-check window the check actually exists for. One was a test that could not observe its subject: it dropped the disposed lease before collecting, so the engine was unreachable either way. One was dead code — an early return in CartridgeLease.Dispose that could not change behaviour, since clearing the release delegate is what makes disposal idempotent — and it was deleted, following the disposition RoomGrain.ReturnToAnchor already records for a guard that cannot fire. The sweep now kills sixteen of sixteen.

Enforcement (ADR-045)

  • AlcCartridgeLoaderTests.ReleasingTheLastHoldMakesTheLoadContextCollectible — the WeakReference assertion, with AContextNobodyHoldsIsCollectible as its harness control.
  • AlcCartridgeLoaderTests.TwoRoomsOnOneCartridgeShareOneContext and ACartridgeStillHeldByOneRoomIsNotSwept — the per-silo rule and the reference count.
  • AlcCartridgeLoaderTests.ACartridgeStaysLoadedForTheWholeLingerWindowQ-097.
  • CartridgeLeaseTests.ADisposedLeaseWillNotHandBackAnEngine — the structural half.
  • CartridgeArchiveModeTests.EveryHoldTakenIsGivenBackWhenTheRoomsDeactivate and AnUnentitledRoomNeverReachesTheLoader — clauses 4 and 5.

Links: Depends-on 033 (the mailbox discipline that makes the load a dispatched stage rather than an awaited activation step); Depends-on 055 (the SDK-major admission this loader applies at load time); Depends-on 132 (the entitlement decision the load is placed after, so a locked room’s context never boots); Narrows §4.4; introduces Q-097; interacts with 037, 020.


ADR-135 — Archive Mode is a property of the cartridge binding, never of the room lifecycle

Status: Accepted Date: 2026-08-09

Context

ADR-020 requires that a room whose events were written by a cartridge that can no longer be loaded must still open, “in read-only Archive Mode — history, replay and export all work while new commands are refused with an explicit reason”.

The obvious implementation is a RoomLifecycle value. It is wrong, and the corpus already paid to find out why. RoomGrain.Guard() is a switch over the lifecycle, and every command routes through it — so an Archived lifecycle value refuses movement, chat, leases and the tick along with everything else. ADR-126 rejected implementing move inside the cartridges in as many words: “it makes movement ruleset-specific, so a room whose cartridge cannot load could not move a token, against ADR-020’s Archive Mode.” A lifecycle value restores exactly the outcome that ruling paid to avoid — and it would have passed every test that existed, because nothing asserted the boundary.

Decision

  1. A room whose cartridge cannot load reaches Ready holding an archived lease. It ticks, replicates, moves tokens, advances fog, accepts chat and grants leases.
  2. The refusal is scoped to cartridge-adjudicated commandsSubmitActionAsync and AuthoriseAttributeAsync, which are the two paths that consult an IRulesetEngine.
  3. The refusal has its own status, ActionStatus.RulesetArchived, and it is answered before UnknownRuleset. An archived room has no engines, so every ruleset id looks unknown to it, and reporting that would tell a game master to load a cartridge the platform has already refused to load.
  4. The reason is on the lease and is readable, through IRoomGrain.GetCartridgeArchiveReasonAsync. A room in Archive Mode is Ready and ticking, so the lifecycle alone cannot tell an operator that a cartridge failed to load — which is the silence that would let a signing misconfiguration reach production looking like uptime.

Alternatives considered and why rejected

  • A RoomLifecycle.Archived value. Rejected, above. It is the reading the next author will propose, which is why the argument is written down rather than left in a code comment.
  • Reusing UnknownRuleset. Rejected. “That actor names a ruleset nobody registered” is fixed by loading the right cartridge; “the platform could not verify or load the one you named” is fixed by an operator and by nobody at the table. A client shown the first when the second is true tells the player to do something that cannot help.
  • Refusing every command, reading ADR-020’s “new commands” literally. Rejected. ADR-126 is the authority on what movement is, and it is not a command a cartridge adjudicates. The players of a room whose cartridge broke can still move, look and talk; what they cannot do is roll.
  • Per-ruleset archiving inside a mixed-cartridge room. Not rejected — out of scope, and the scope is stated because §3.1 uses the per-ruleset wording. ADR-132 binds exactly one cartridge per room, so “that ruleset read-only” and “this room’s rules read-only” currently name the same set. The lease is keyed by ruleset id, so a mixed room would archive per ruleset without a redesign; nothing exercises that today and nothing here claims it works.

Consequences

  • ADR-020’s guarantee becomes checkable at the room boundary rather than only at the event store: CartridgeArchiveModeTests asserts the room opens, ticks, moves and chats.
  • An operator surface exists for a failure that is otherwise invisible.
  • What this does not close: ADR-065’s recorded-session corpus is the enforcement point ADR-020 names for “data survives the assembly”, and it still replays with the authoring assemblies absent rather than with a loader that refused them. Those are different scenarios and only the first is covered.

Links: Amends 020 (its “new commands are refused” clause, scoped to cartridge-adjudicated commands); Depends-on 126 (movement being adjudicated by nothing, which is what makes a read-only room playable); Depends-on 134 (the lease whose archived state carries the reason); interacts with 055, 132.


ADR-136 — Provenance is established over a signed manifest, not over an assembly

Status: Accepted Date: 2026-08-09

Context

§6.4’s P0 row says a .NET cartridge runs with full host privileges and is admitted from “first-party and signed, code-reviewed partners only. Requires signature verification at load.” Until this ruling nothing verified anything, so the P0 row was a policy with no mechanical counterpart — the trust tier rested entirely on a review gate outside the code.

ADR-098 fixes what such a check may be claimed to buy: “cryptographic signing establishes provenance and never safety, so a correctly-signed bundle exceeding Q-017 is still rejected.” That constraint is carried here unchanged and stated in the verifier’s own documentation, because the tempting next sentence — “cartridges are signed, therefore cartridges are safe” — is exactly what ADR-098 exists to forbid.

Decision

  1. The signature covers a manifest; the manifest covers the assemblies. A cartridge directory holds cartridge.json, a detached cartridge.json.sig, and the assemblies. The manifest names the urn, the version, the entry assembly, the publisher, the signature suite, and a SHA-256 digest for every assembly the context may load.
  2. The file set is closed. The load context refuses to resolve any assembly the manifest does not name, and verifies each one’s digest as it loads it.
  3. The verified manifest’s urn is checked against the urn that was asked for.
  4. Trust anchors are host configuration and ship no default. An empty publisher list is refused at startup, never read as “trust everything”.
  5. The suite is ecdsa-p256-sha256 and is named in the manifest, so a second suite is a second registered verifier rather than a re-signing flag day.
  6. Every failure archives the room; none fails it. ADR-020 is a promise to the campaign, not to the loader.

Alternatives considered and why rejected

  • Signing only the entry assembly. Rejected, and it is the design most people would reach for. It leaves the urn unsigned — so anyone who can write the cartridge directory can point a free urn at a paid cartridge’s image, and ADR-132’s Tier A gate authorises the wrong thing. An entitlement bypass reached without touching a line of code. It also leaves every dependency unsigned, and a cartridge’s rules can live entirely in a library beside it.
  • Authenticode. Rejected: verification is Windows-only and the silo is a Linux container in every deployed profile (§10.1). A control enforced on a developer’s machine and absent in production is worse than none, because it reads as enforced.
  • Strong naming. Rejected: a strong name is an identity, not a signature. The public key travels with the assembly and .NET Core does not verify it at load, so it establishes nothing.
  • ML-DSA (FIPS 204) as the only suite. Rejected for now, with the reason recorded because it is the right destination. .NET 10 exposes it, but support is conditional on the platform’s crypto library — MLDsa.IsSupported is false on an older base image — and the failure mode of getting that wrong is a silo where every room is in Archive Mode. Clause 5 is what makes adopting it later cheap.
  • Verifying once at startup and caching the result. Rejected: it caches a decision taken minutes before the assembly it authorises is read, which is a time-of-check to time-of-use gap in the one component whose job is to close it. Discovery scans at startup; trust is established at load, over bytes re-read then.
  • Refusing to start when a cartridge fails to verify. Rejected as a violation of ADR-020: an unverifiable cartridge must archive its rooms, not take down the silo serving every other one.

Consequences

  • The P0 tier has an enforcement point for the first time.
  • A publishing tool must exist to produce the manifest and the signature. It does not exist yet — the signing side is currently only in SignedCartridgeDirectory, the test fixture, and that is a gap rather than a plan.
  • Signing material is per-deployment configuration. There is none in this repository and clause 4 is what keeps that true.
  • What this does not check, stated so the guarantee is not read wider than it is: the digest proves the bytes are what the publisher signed. It says nothing about what those bytes do — ADR-098’s point exactly — and the review gate §6.4 names remains a human one.
  • One case is unexercised: a private assembly dropped into a signed cartridge’s directory and not named in the manifest is unreachable through the context, but no test drives it, because it would need a cartridge that references an assembly its own publisher did not sign.

Enforcement (ADR-045)

  • AlcCartridgeLoaderTests.ATamperedAssemblyIsRefusedAndTheRoomStillOpens — clause 2, and the room-still-opens half of clause 6.
  • AlcCartridgeLoaderTests.AManifestSignedByAKeyThisHostDoesNotTrustIsRefused — clause 4.
  • AlcCartridgeLoaderTests.RewritingTheUrnAfterSigningIsRefused — clauses 1 and 3.
  • CartridgeLoaderOptionsTests.SignedDirectoryWithNoTrustAnchorsIsRefused — clause 4’s startup gate.
  • AlcCartridgeLoaderTests.TheCartridgeGetsItsOwnContextAndTheHostsSdkAtTheSameTime — the shared-contract rule the whole scheme rests on.

Links: Depends-on 098 (signing establishes provenance and never safety, carried unchanged); Depends-on 132 (the urn the manifest binds, and the Tier A check a re-pointed urn would bypass); Depends-on 134 (the load path this check gates); interacts with 020, 055.