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
| Seam | Side | Open to a host? | Contract |
|---|---|---|---|
Kind | Inside — is the game logic | No — engine-owned (§7) | 04 §3 |
Condition operators | Inside — evaluated during resolution | No — frozen set | 04 §18 |
IdSource | Outside — values enter state but are opaque to it | Yes | §5.1 |
RecordIdSource | Outside — session and save ids, which never enter state at all | Yes | §5.7 |
SessionStore | Outside, but core-owned — locking, stamping and upsert live in it | No — supply SessionPersistence instead | §5.2 |
SessionPersistence | Outside — reads and writes the store's records | Yes | §5.2 |
ProfileStore | Outside — durable, beside the session store | Yes | §5.2 |
Emitter | Outside — write-only, returns void | Yes | 05 §4 |
Clock | Outside — boundary only, never reaches the core | Yes | §5.4 |
ExperimentSource | Above the session seam — resolves variants used to select packs; its narrowed result tags events | Yes | §5.5 |
| Clients | Above everything — presentation only | Yes, no registration needed | 02 §1 |
| Campaigns, content packs | Data, validated in tiers | Yes, already the content path | 04 §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: SessionStoreis what this field used to be, and the change is the point. Handing the root a finishedSessionStoreand 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, andcreateSessionLayercomposes the real store around it.
experimentsis the resolved assignment map, not the source that produced it. W59'sExperimentSource,resolveBucketKey,resolveExperimentAssignmentsandapplyExperimentGatesremain host-side composition above this seam. The host filters the candidate packs, resolves the registry, builds the matchingEngine, removesnull(not-enrolled) entries from the assignment map, and supplies the remaining assignments here. The session layer stamps that map unchanged onto everyEmittedRecord(05 §6). Candidate packs never cross this boundary: accepting them here would make the store responsible for content resolution even though both itsEngineandContentRegistryare 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:
createGamebecomes testable. With a countingIdSource, a fixture produces the same envelope every run — includinggameId— 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
gameIdout of a golden event stream precisely because it is unpredictable. Under a fixedIdSourceit 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
deserializeaccepts. - 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 whatStoredSessionRecordis (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. Setnameto the exportedSESSION_PERSISTENCE_CONFLICTstring — the store matches on that, not oninstanceof, 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 asstorage_failure, which is correct for them. Hosts with a single writer — every in-process andlocalStorageadapter — 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 —
definein Vite, and the equivalent elsewhere. A browser bundle that omits it gets the fallback, and thetypeof processguard 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/spanIdare 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:
- Establish which side of §2's line it is on. If a host implementation could change
serialize()output, it is not a port. Stop. - Define it as an interface in the
compositionmodule (04 §1.1), not beside its first consumer. - Give it a default that works, and make the field optional.
- Add it to the relevant root —
EngineHostif the pure engine needs it,SessionHostif only the boundary does. - State the implementer's obligations, as §5.2 does. An interface without them is a shape, not a contract.
- 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:
| Want | Path | Needs |
|---|---|---|
| A new game of an existing kind | A campaign — data | Nothing new |
| A new type of game | A kind, in-tree | Ordinary engine work |
| Different storage, logging, ids | A port (§5) | Nothing new |
| A stranger's kind, unreviewed | — | Reopening 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
formatVersiondiscipline), 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.mdwith 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.