Skip to main content

Extensibility — Ports and Seams

Document status: Revision 1 — new contract, MVP scope

Reading order: after 04-core.md, whose types this organises, and alongside 05-observability.md, whose Emitter is the worked example every port here follows.

Scope of this document

Where the engine can be extended, by whom, and the single rule that decides. It names the ports a host may supply, the one place they are supplied, and what is deliberately not open.

It does not reopen 02-architecture.md §1 decision N2. Kinds remain engine-owned code; §7 says what that does and does not permit.


1. What This Is, and What It Is Not

This is not a plugin system. There is no manifest, no loader, no dynamic discovery, no marketplace. Adding an extension means writing an implementation and passing it in at construction — a compile-time decision, reviewed like any other code.

That is the honest scope, and it is chosen rather than settled for. A general plugin system solves a problem this platform does not yet have, and the shape it tends to grow into — a rules language expressive enough for arbitrary third-party behaviour — is the one architecture §1 explicitly identifies as "where narrative engines die."

What it is: a stated set of ports — interfaces the engine depends on and a host supplies — so that swapping persistence, observability, or identity is a documented one-line change rather than an archaeology exercise.

Why this document exists at all, given nothing is currently blocked. Three seams already existed before it, and they were supplied three different ways: a registry (KindRegistry, ContentRegistry), a constructor parameter (Emitter, 05 §4), and "implement the interface and wire it yourself" (SessionStore, ProfileStore, 04 §7). Adding a fourth meant picking one of three conventions arbitrarily. That inconsistency is what makes an engine feel closed — not the absence of a loader.


2. The Rule

One rule decides every question in this document:

A host may supply anything that cannot change serialize() output.

Everything else follows. The rule is not a guideline: it is checkable by the determinism harness already specified in 04 §14 and 05 §12, because "cannot change serialize() output" is exactly what those fixtures assert.

Its consequence is the useful part:

The determinism boundary is the trust boundary. They are the same line. Code that cannot affect replay also cannot affect the outcome of a game, so it needs no sandbox, no capability system, and no review beyond ordinary engineering. Code that can affect replay needs all three — which is why it is not open (§7).

That equivalence is what makes this document short. There is no risk gradient to manage, no partial trust to model. A seam is on one side of the line or the other.


3. The Seam Map

SeamSideOpen to a host?Contract
KindInside — is the game logicNo — engine-owned (§7)04 §3
Condition operatorsInside — evaluated during resolutionNo — frozen set04 §18
IdSourceOutside — values enter state but are opaque to itYes§5.1
RecordIdSourceOutside — session and save ids, which never enter state at allYes§5.7
SessionStoreOutside, but core-owned — locking, stamping and upsert live in itNo — supply SessionPersistence instead§5.2
SessionPersistenceOutside — reads and writes the store's recordsYes§5.2
ProfileStoreOutside — durable, beside the session storeYes§5.2
EmitterOutside — write-only, returns voidYes05 §4
ClockOutside — boundary only, never reaches the coreYes§5.4
ExperimentSourceAbove the session seam — resolves variants used to select packs; its narrowed result tags eventsYes§5.5
ClientsAbove everything — presentation onlyYes, no registration needed02 §1
Campaigns, content packsData, validated in tiersYes, already the content path04 §10.1

Two entries are worth reading twice.

IdSource is inside-looking but outside the line. A gameId is written into the envelope and therefore into serialize() output, which looks like a violation. It is not: the engine never interprets the value, never branches on it, and never derives anything from it. Replay determinism is a property of a fixture holding its inputs fixed, and gameId is an input like seed. §5.1 makes that explicit rather than leaving it to be inferred.

Clock never crosses into the core at all. The core has no clock and the eslint guard enforces it (04 §2). The port exists so the boundary — the session store, stamping records (05 §6) — has a seam a test can freeze, not so the engine gains a way to ask the time.


4. The Composition Root

Every port is supplied in exactly one place, in exactly one shape.

interface EngineHost {
readonly kinds: KindRegistry; // 04 §4 — engine-owned, not host-extensible (§7)
readonly registry: ContentRegistry; // 04 §10.1
readonly ids?: IdSource; // §5.1 — defaults to a random source
readonly emitter?: Emitter; // 05 §4 — defaults to nullEmitter
}

function createEngine(host: EngineHost): Engine;

Stores are composed one layer out, because they sit above the pure engine (04 §1):

interface SessionHost {
readonly engine: Engine;
readonly registry: ContentRegistry; // 04 §10.1 — listCampaigns and getStrings read it
readonly persistence?: SessionPersistence; // §5.2 — omitted → in-memory only (04 §7.2)
readonly profiles?: ProfileStore; // §5.2 — omitted → anonymous-only (04 §7.1)
readonly clock?: Clock; // §5.4 — defaults to the system clock
readonly recordSink?: EmittedRecordSink; // 05 §6 — omitted → records are discarded
readonly experiments?: Readonly<Record<string, string>>; // §5.5 — resolved, enrolled assignments only
readonly recordIds?: RecordIdSource; // §5.7 — omitted → the layer mints its own
}

function createSessionLayer(host: SessionHost): SessionStore;

sessions: SessionStore is what this field used to be, and the change is the point. Handing the root a finished SessionStore and asking it to return one only made sense if the field meant a lower-level, storage-only port — which nothing named. It does now: SessionPersistence (04 §7.2) is that port, and createSessionLayer composes the real store around it.

experiments is the resolved assignment map, not the source that produced it. W59's ExperimentSource, resolveBucketKey, resolveExperimentAssignments and applyExperimentGates remain host-side composition above this seam. The host filters the candidate packs, resolves the registry, builds the matching Engine, removes null (not-enrolled) entries from the assignment map, and supplies the remaining assignments here. The session layer stamps that map unchanged onto every EmittedRecord (05 §6). Candidate packs never cross this boundary: accepting them here would make the store responsible for content resolution even though both its Engine and ContentRegistry are already fixed. Omission means no experiment attribution, and the core still never sees an assignment. The map applies to every session created through this layer, so a host running more than one assignment combination builds and routes to one session layer per matching {Engine, ContentRegistry, experiments} tuple.

Three rules make this uniform, and they are the whole convention:

  • Every port is an interface, supplied by value. Never a subclass, never a mutated global, never a module the engine imports by name. The dependency arrow keeps pointing inward (04 §1.1).
  • Every optional port has a default that works. An embedder who supplies nothing gets a functioning engine. Optionality is what keeps the surface honest: a port nobody can usefully default is probably not a port.
  • A port is supplied once, at construction, and never swapped afterwards. Replacing a store mid-session is not a supported operation, and permitting it would make every invariant in 04 conditional on when it was asked.

Why two roots rather than one. The split mirrors 04 §1's two layers exactly. The pure engine must be constructible with no I/O at all — that is what makes the determinism harness able to run it (04 §14) — so anything doing I/O has to be composable around it rather than into it. One combined root would let a store be handed to the pure engine, which is the coupling the two-layer split exists to prevent.


5. The Port Catalogue

5.1 IdSource — the one that was missing

interface IdSource {
/** A new game id. Opaque to the engine: never parsed, compared, or derived from. */
newGameId(): string;
/** A new seed, when NewGameConfig omits one (04 §7). */
newSeed(): string;
}

This port closes a real gap rather than anticipating one. gameId was declared on the envelope (04 §2) and consumed by createGame (04 §4), but no document said where the value came from; seed was only "store-generated", with no named mechanism. Those are the only two non-deterministic value sources in the engine, and neither was a seam.

Three things follow from naming it:

  • createGame becomes testable. With a counting IdSource, a fixture produces the same envelope every run — including gameId — which is what lets a golden file cover game creation instead of starting one action later.
  • The observability stream comparison gets simpler. 05 §5 has to normalize gameId out of a golden event stream precisely because it is unpredictable. Under a fixed IdSource it is predictable, and the normalization becomes a convenience rather than a requirement.
  • A host that needs ids to mean something can have that. ULIDs for sortability, a namespaced id for a multi-tenant deployment. The engine does not care, and the rule in §2 is why it can afford not to.

The default is a random source. It is the one place in the platform where randomness is correct — a fresh game genuinely should not be predictable — and it lives outside the pure engine, so the determinism guard's ban on Math.random under src/core/ stands unqualified.

5.2 SessionPersistence and ProfileStore

SessionStore is not the port; what sits under it is. The store's own job — the two lock domains (04 §7), the trace-and-stamp decorator (05 §6.1), save-envelope assembly (04 §10.2), and the idempotent profile upsert (04 §7.1) — is behaviour every host needs and no host should reimplement. A SessionStore supplied wholesale by a host is four invariants nobody checks.

So the seam is drawn one level down. SessionPersistence (04 §7.2) is a pair of record stores — get and put a StoredSessionRecord, get, put and delete a StoredSaveRecord — and createSessionLayer composes the real store around whichever one it is given. Omit it and the store's own maps are the whole implementation, which is the MVP default and what every test runs against.

A host may supply Postgres, Redis, SQLite, a file, or localStorage. The obligations:

  • Persist the canonical serialization, not live objects (04 §7). A store that keeps object graphs will drift from what deserialize accepts.
  • Address a save by its saveId (04 §7.2). An adapter keyed on anything else writes successfully and reads nothing, and no gate catches it.
  • Never write host metadata into GameState. Timestamps, owner ids, and tenancy live on the record, which is exactly what StoredSessionRecord is (04 §2, §7.2).
  • Throwing is allowed and is not a game failure. The store converts any adapter exception into storage_failure (04 §7.2) rather than letting a host's own exception type cross the boundary.
  • Brand a lost-update conflict, and nothing else, as SessionPersistenceConflict. It is the one adapter failure the store classifies rather than flattening (04 §7.2), so it is reserved for the case it names: a session write this adapter rejected because another writer changed the same record after this request read it. Set name to the exported SESSION_PERSISTENCE_CONFLICT string — the store matches on that, not on instanceof, so the signal survives a duplicated copy of this package. A host that brands a timeout, a deadlock, or a retryable transport error with it is telling a player to refresh a session that never changed; leave those unbranded and they arrive as storage_failure, which is correct for them. Hosts with a single writer — every in-process and localStorage adapter — never raise it, and need do nothing.

ProfileStore (04 §7.1) is unchanged and remains a port in the original sense — a host supplies the whole thing. Its obligations:

  • A failed profile write must not roll back a game action (04 §7.1). The game is the system of record; the profile is a projection of it.
  • A missing or corrupt profile degrades to "no achievements", never to a broken game (04 §7.1, 03 §7).

5.3 Emitter

Specified in full at 05-observability.md §4 and §10. It is listed here because it is the precedent every other port follows: an interface, supplied at construction, defaulted to a no-op, returning void so nothing about the host can reach the game.

An OpenTelemetry exporter is an Emitter at the boundary — which is why 05 §13 can defer it as an adapter rather than a redesign.

5.4 Clock

interface Clock {
/** ISO-8601 now. Boundary only — the core never receives this port. */
now(): string;
}

Used by the session store for record timestamps and by the observability boundary for emittedAt (05 §6). It exists so those can be frozen in a test, and so the single place the platform reads a clock is a named one.

It is deliberately absent from EngineHost. Handing a clock to the pure engine would make Date.now reachable from inside the determinism boundary through a supported API — undoing by convenience what the eslint guard enforces by rule.

5.5 ExperimentSource

interface ExperimentSource {
/** A stable variant for one experiment, or `null` if `bucketKey` is not enrolled.
* Boundary only — the core never receives this port, and its result never enters
* `GameState`. */
resolve(experimentId: string, bucketKey: string): string | null;
}

Resolves an A/B or feature-flag assignment, at session-creation time, for content-pack selection (11 §5a). It is a host-side, optional port above SessionHost: the host uses it while it still has the candidate packs, then passes only the resolved, non-null assignment map into SessionHost.experiments for event attribution (05 §6). A variant that could reach the pure engine and be branched on inside advance would reopen the universal-DSL pressure architecture N2 already rejected once (§7).

Nothing about a kind's behaviour is gated through this port. A kind cannot see which variant a game is in, cannot ask, and has no field to ask through. What varies is which content the kind is handed — §2's line, unmoved: a host may supply anything that cannot change serialize() output, and a resolved variant only ever changes which packs get selected before resolvePacks runs, at a stage the pure engine never observes.

bucketKey is profileId when the session is profiled, else the session's seed. The host computes this once — the fallback rule the design calls for — before calling resolve, rather than handing every ExperimentSource a raw profileId | null and asking each implementation to reimplement the same fallback; that would be exactly the kind of incidental divergence §2's boundary rule exists to prevent. It is also what keeps anonymous sessions from collapsing onto one shared assignment: each has its own seed (04 §7), generated before pack selection runs, so each anonymous session still buckets deterministically — just not stably across sessions the way a profileId does. profileId itself is the same value CreateSessionConfig already carries (04 §7.1) and that GameState is barred from ever holding — folding it into bucketKey crosses no new line, because only the result of resolve continues past the call, exactly as IdSource's randomness never crosses in, only its output does (§3).

An experimenting host must materialize an omitted seed before choosing the assignment and pass that same seed into createSession; otherwise it would bucket on one value and persist another. It may use the same IdSource instance it supplies to createEngine, or an equivalent host-owned source. The ordinary no-experiment path keeps the existing default in which the engine generates an omitted seed — only a host that needs the seed for routing has to move that generation one step outward.

null means "not enrolled," and it is a different value from any legal variant. ExperimentGate.variant (11 §2) is an unconstrained string, so a fixed-string default — always returning "control", say — risks colliding with whatever string a gate happens to use, silently enabling a pack no experiment actually assigned. Returning null instead is structurally incapable of that: assignments[gate.experimentId] === gate.variant (11 §5a) can never be true when the left side is null, so "no experiments running" — the default, when experiments is omitted — is a guarantee, not a coincidence of which string was picked.

Where the registry a variant selects actually lands. createEngine binds one ContentRegistry at construction and never swaps it (§4), so a host running experiments does not resolve packs once, globally — it resolves once per distinct assignment combination, via applyExperimentGates + resolvePacks (11 §5a), and builds one Engine and one SessionStore per resulting registry and narrowed assignment map, keyed by the ResolutionId that resolution already produces (11 §6). Routing a given createSession call to the right pre-built session layer, having already resolved that session's assignments, is host-side composition above this seam — the same way wiring a request to createSession at all is — and this document does not constrain it further.

A real implementation's only obligation: be a pure function of its two arguments. Two calls with the same (experimentId, bucketKey) must return the same variant, or pack selection stops being reproducible from the assignment alone — the property 11 §6's identity mechanism depends on. The bucketing algorithm itself — hash choice, rollout percentage, sticky-session semantics beyond what bucketKey already gives for free — is a host decision this document does not constrain, the same way SessionStore's storage backend is not constrained (§5.2).

5.6 The One Build-Time Flag — __GAME_ENGINE_PRODUCTION__

Not a port, and listed here so it is not mistaken for a missing one. The observability emitter needs to know whether it is running in a shipped build, so that dev-only work can be compiled out rather than branched around at runtime. A port cannot do that: a value supplied at construction is still there for a bundler to keep.

__GAME_ENGINE_PRODUCTION__: boolean // replaced at build time; declared, never imported
  • Node hosts define nothing. When the global is absent the engine falls back to process.env.NODE_ENV === "production", which is what every Node caller already sets.
  • Browser hosts must define it. A bundler substitutes a literal — define in Vite, and the equivalent elsewhere. A browser bundle that omits it gets the fallback, and the typeof process guard means that resolves to not production: dev-mode emitter behaviour, shipped, silently.
  • It cannot change serialize() output, which is why it is allowed to exist at all (§2). It gates emission work, and 05 §2 already guarantees dropping every event changes nothing.

The asymmetry — Node defaults correctly, browsers must act — is the whole reason this is written down rather than left to the bundler config that happens to set it today.

5.7 RecordIdSource — session and save ids

interface RecordIdSource {
/** A new session id. Store metadata: never enters `GameState`. */
newSessionId(): string;
/** A new save id, minted per `saveGame`. Same category. */
newSaveId(): string;
}

A second port beside IdSource, not a widening of it, and the distinction is the reason it exists. IdSource supplies gameId and seed, which are written into the envelope and are replay inputs — §3 spends a paragraph explaining why that is still outside §2's line. A session id and a save id are never in the envelope at all: they key StoredSessionRecord and StoredSaveRecord (04 §7.2), which is host metadata by construction. Folding them into IdSource would collapse "an opaque input the engine records" and "a key the engine never sees" into one port, and a host wanting deterministic save ids would then also be redefining the game's seed.

Supplied on SessionHost rather than EngineHost for that same reason — the pure engine has no session and no save.

The default is random (crypto.randomUUID(), matching IdSource's own choice), and it is byte-for-byte what the session layer already minted before the port existed, so supplying the default changes nothing. Omit it and the behaviour is identical to omitting it having never been declared.

The implementer's obligations:

  • Return a value unique within the store. A repeated session id overwrites a live session's record; a repeated save id overwrites a checkpoint. Nothing downstream checks — the store treats a returned id as authoritative.
  • Do not derive it from game state. It is not a replay input and must not become one; a session id computed from the seed would make two runs of the same fixture collide.
  • traceId/spanId are not covered. Those stay minted internally (05 §6.1) — they are per-command correlation, not records a host addresses.

Its determinism assertion is the ordinary §6 step 6: a fixture replays byte-identically under the random default and under a counting implementation, because neither id reaches serialize().


6. Adding a Port

The checklist, so a future seam does not invent a fourth convention:

  1. Establish which side of §2's line it is on. If a host implementation could change serialize() output, it is not a port. Stop.
  2. Define it as an interface in the composition module (04 §1.1), not beside its first consumer.
  3. Give it a default that works, and make the field optional.
  4. Add it to the relevant rootEngineHost if the pure engine needs it, SessionHost if only the boundary does.
  5. State the implementer's obligations, as §5.2 does. An interface without them is a shape, not a contract.
  6. Add a determinism assertion: a fixture replays byte-identically under the default implementation and under a deliberately different one. This is the executable form of step 1, and it is what catches a port that turned out to be inside the line.

7. Kinds Stay Engine-Owned

A new kind — a new category of game — is an engine feature. It is written in this repository, under kinds/, reviewed and compiled in, exactly as simulation will be alongside story-graph. That is not a restriction on what the platform can host; it is a statement about who writes it.

Architecture §1 decision N2 settled this, and nothing here disturbs it:

downloadable code kinds put arbitrary code inside a hosted deterministic engine, a security and reproducibility hazard. Engine-owned kinds draw the clean line: kind = code (engine-owned), campaign = data (author-owned).

The reasoning is §2's rule read from the other end. A kind's advance is the game logic — it is as far inside the determinism boundary as code can be. Third-party code there could call a clock, draw unseeded randomness, or diverge across engine versions, and byte-identical replay would stop being a property the platform can assert.

What this permits, and what it does not:

WantPathNeeds
A new game of an existing kindA campaign — dataNothing new
A new type of gameA kind, in-treeOrdinary engine work
Different storage, logging, idsA port (§5)Nothing new
A stranger's kind, unreviewedReopening N2 (§9)

Only the last is blocked, and it is blocked deliberately.


8. Keeping the Door Open, Cheaply

Third-party extension is not decided against forever — N2 rejected one mechanism (downloadable code kinds) for stated reasons, and a different mechanism with a real sandbox is a decision that can be revisited. The conventions above are chosen so that revisiting it later costs no rework:

  • Interfaces, not inheritance, so an implementation can be replaced by one that forwards across a sandbox boundary without changing a caller.
  • All host code outside the determinism boundary, so the trust boundary is already drawn in the right place. Opening up later means sandboxing exactly one seam — kinds — rather than auditing every seam at once.
  • Ports are versioned with the contracts that own them (04 §10.2's formatVersion discipline), so an out-of-tree implementation can state what it was built against.

That is the whole insurance policy, and it is close to free. What is deliberately not being built now is the sandbox, the manifest, the loader, and the capability model — none of which has a consumer, and all of which are cheaper to design against a real requirement than against an imagined one.


9. What Is Deferred

Named so the omissions are decisions rather than gaps:

  • Third-party kinds, and the sandbox they require. A WASM host with a deterministic ABI — no clock, no ambient float nondeterminism, fuel-metered — is the shape that could satisfy §2's rule. It reopens N2 and changes the platform's security model, so it belongs in OPEN-QUESTIONS.md with its cost stated, not in a spec.
  • Dynamic loading and manifests. Compile-time composition is sufficient for first-party extension and has no discovery problem to solve.
  • A localization source port. String tables come from the content registry (04 §10.1) today. Worth a port when a host needs strings from somewhere else; not before.
  • Capability declaration and per-port permissions. Meaningful only once code is supplied by someone who is not trusted, which is the first item.