Skip to content

Runtime contract

Lute is a total, side-effect-free compiler. lute compile <file> checks a .lute document and lowers it to a JSON artifact — and then stops. It runs no CEL, no Datalog fixpoint, keeps no fact store, fires no bridge. Every behavior lives on the far side of the artifact, in the engine. This page is the condensed runtime contract; the full, source-grounded specification is in docs/runtime/. The lute.engine.yaml matrix format is specified in 0.33.0.md §4. schemas/lute-ir-0.53.schema.json (JSON Schema draft 2020-12).

For the resumable runtime API and its JSON wire format, see the runtime event contract. The lute-runtime crate exposes the pure integration surface: load a lute compile --project DIR -o OUT bundle with Runtime::load, create a session with begin, advance it with step, and use snapshot/restore plus the read-only queries for host inspection. Hosts own rendering, pacing, bridge calls, and grant settlement; the runtime returns those effects as data.

TypeScript hosts can use @lute-lang/runtime, an Effect 4 API over the wasm runtime. Its Bun and browser layers load a lute compile --project DIR -o OUT bundle, expose scoped sessions and event streams, and provide typed rejections, snapshots, and replay. See the full TypeScript runtime guide for installation, a complete Bun session, browser serving, program queries, errors, generated contract schemas, and Replay.

In 0.41 a Program adds gate(state, occasion, target?) (Effect<Gate, InvalidInput>) and def(state, name, args?), whose args are one JSON scalar per declared parameter; a closed raise fails with Errors.OccasionGateClosed (E-OCCASION-GATE) and leaves the state unchanged. Session.Session (service type Session.SessionShape) exposes program, the Program it loaded, so a host queries current’s state without loading the bundle twice. A RuntimeState is valid for every Program of the same bundle fingerprint; handing it to a program of another fingerprint is a defect. Bundle.index is typed Ir.projectIndex.

For a Bun host, install the runtime with both optional platform peers pinned to the same Effect release:

Terminal window
bun add @lute-lang/runtime effect@4.0.2 @effect/platform-bun@4.0.2 @effect/platform-node-shared@4.0.2

For local packed tarballs, replace @lute-lang/runtime with the file:<runtime-tarball> printed by scripts/pack-local.sh (and paste its printed dependencies and overrides entries), then keep the same effect@4.0.2, @effect/platform-bun@4.0.2, and @effect/platform-node-shared@4.0.2 arguments.

To try an unreleased checkout in a host, run bash scripts/pack-local.sh [--web] [OUT_DIR] in the Lute repository: it packs the runtime and player (and player-web with --web) with bun pm pack and prints the dependencies and overrides entries (file:<tarball>) to paste into the host’s package.json; the host keeps its own effect 4.0.2. Do not link file:../lute/packages/* directories: they resolve the Lute repository’s own effect, giving the host two effect instances. See Local dogfooding.

Program.clock(state) is numeric: it returns the day and the zero-based slot index. Slot names and weekday labels come from program.view(state).clock, whose ClockView has { day, slot, weekday, weekdayLabel, ended, last }. For an Effect-style HUD:

import { Effect, Option } from "effect"
const hud = Effect.gen(function* () {
const position = yield* program.clock(state)
const world = yield* program.view(state)
if (Option.isNone(position) || world.clock == null) return "No clock"
const clock = world.clock
return `${clock.weekdayLabel ?? `Day ${clock.day}`} — ${clock.slot ?? `slot ${position.value.slot}`}`
})

Keep the numeric position for ordering, and use ClockView.slot and ClockView.weekdayLabel for display text.

Every 0.52 IR change is additive: a project that uses none of the new constructs compiles to byte-identical artifacts, capabilitySnapshot included. The contract schemas are renamed to the -0.52 line. One event shape changes: the awaitChoice menu carries rendered text.

A jump that leaves a hub. A jump command gains an optional leaves (an integer ≥ 1): the number of hub option or <return> bodies between the jump and its target’s body, written only when the jump leaves at least one (Leaving a hub). A bounded jump never carries it.

  • New semantic id lute.core.hubLeave/1 (module core, behaviour “jumps that leave a hub”, provenance hub leave), collected only by an artifact with a jump that carries leaves. An engine that ran the 0.51 behaviour would present the hub again, so it refuses such an artifact at semantic negotiation. The registry goes from 17 to 18 ids.
  • Runtime. The jump ends its range at once; the hub visit around it ends (no <return> body runs, the menu is not presented again), and with leaves = n the n innermost hub visits end and control continues at the target. A quest segment and the top-level range never receive a leave.
  • Load checks. The reference runtime refuses, at load, a leaves that does not reach the target exactly: removing the n innermost range bodies around the jump must leave the target’s body innermost, and each removed body must be a hub option or <return> range (this refuses an undercount, an overcount and a sibling body). A jump with both max and leaves is refused, and so is a transfer into a hub option or <return> body from outside it (such a body is entered only through its hub record). Compiled artifacts never have these. Where an inner hub is the last laid-out non-exit option of an outer hub with no <return>, leaves: 1 and leaves: 2 both reach the same address and run differently (the outer hub presents again, or does not): leaves is authoritative.
  • No snapshot change. The leave is walk-local; the hub record per presentation, scene.choices.*, scene.visited.*, once and replay are unchanged.

Rendered menu text. The runtime renders each option label and the prompt at every presentation with the line renderer ({{…}} markers by value, the lute 0.47.1 reading of \{{ and backslash runs, translations by marker as a line’s texts are). The awaitChoice menu carries the result: prompt (now set when the branch or hub has one) and promptTexts (locale → text, omitted when empty), and on each option text (always) and texts (omitted when empty). A host token and a marker whose path is unset keep their written text; menu text never halts the walk. The hub presentation record’s prompt is the rendered prompt. Because the pending Await is part of the Snapshot, the snapshot schema changes too. A host shows the menu’s text instead of rendering the IR’s (see Custom hosts).

  • promptPlaceholders. A choice or hub command with a prompt that holds a marker gains promptPlaceholders, built as an option’s placeholders are. It adds no semantic id; its expressions add the same expression-derived requirements an option label’s do (a prompt that reads a temporal def requires lute.knowledge.temporal/1), and a path placeholder is a content read (a bridge result only a prompt shows is still requested in awaitBridge.results).

Reward-kind attributes. RewardEntry gains attrs: each authored reward-kind attribute and each declared default: not authored, typed as a plugin record’s fields, written last and only when non-empty. The effects bound from them land in the existing retracts/asserts/raises. The grant record’s reward copies attrs (only when non-empty). Descriptive, no semantic id: a host reads attrs by the reward kind’s contract, which capabilitySnapshot pins.

{"index":0,"instance":1,"kind":"grant","objective":"pay","quest":"first","reward":{"amount":1,"attrs":{"rarity":"gold"},"kind":"BADGE","target":"mira"}}

32-bit targets. List indexing, a countDistinct column and the weekday label convert their int operands without truncation on every target (the wasm32 runtime in the reference player-web agrees with native), and lute lint’s list index does the same (#198).

What an engine or host must do. Widen the version gate to 0.52; list lute.core.hubLeave/1 in supportedIds to accept an artifact with a leaving jump, and end the hub visits it leaves; show menu option text and prompts from the awaitChoice menu; optionally read promptPlaceholders and reward attrs.

Every 0.51 IR change is additive: a project that uses none of the new keys compiles to byte-identical artifacts, with one exception: capabilitySnapshot changes once for every artifact, because the core ::jump now declares max. Every other new key (effects.raises, raisedBy, type: text) folds into the hash only for a snapshot that declares it. The contract schemas are renamed to the -0.51 line.

Bounded backward jump. A jump command gains an optional max (an integer from 1 to 99), omitted when the jump has none; a jump without max is byte-identical to 0.50 and still goes only forward. With max, the jump may target an earlier label and is taken at most max times; after that it is not taken and control falls through to the next command.

  • New semantic id lute.core.boundedJump/1 (module core, behaviour “bounded backward jumps”). An artifact lists it in requiredSemantics (provenance bounded jump) only when some jump command has max. An engine that ignores max either loops without bound or never loops, so the id is meaning-changing: an engine that does not list it in supportedIds refuses such an artifact at semantic negotiation, before playback. The registry goes from 16 to 17 ids.
  • Counters. Each bounded jump has a counter local to one walk (one presentation, one quest handler or objective segment, one entry or beat body), starting at 0. When control reaches the jump (and its when holds, if any) with the counter below max, the counter is incremented and control moves to the target; otherwise control falls through. The counter returns to 0 whenever control reaches the jump’s target from outside the jump’s region, which is the span from the target to the jump in the command stream: by falling through into the label, by a forward jump, or by a bounded jump that is not inside the region. A take of the jump itself, or of a bounded jump inside its region, does not reset it. Two bounded jumps sharing a head do not reset each other. A loop never spans an <on> or <objective> boundary, so a region is always inside one range of commands.
  • Termination. Between two takes control moves only forward, so a walk is bounded by the nesting of its loops; a region of nesting depth d runs at most Π(max + 1) times over its enclosing jumps. The reference runtime does not trust the compiler for this: at load it re-checks every control transfer of every record (a jump’s target; a choice or hub option’s target, a hub’s return, converge; a match arm’s target, otherwise, converge; an objective’s, handler’s, entry’s or beat’s body). A transfer to the record itself or to an earlier one is refused unless it is a jump with max in a well-nested region; two bounded loops that overlap without nesting, a max outside 1..=99, and a required target that is missing or not a position are refused too. Runtime::load names the record (malformed `jump` record at 001-0200: its `target` goes back to 001-0100, a cycle that never ends (only a `jump` with `max` may go back)), and lute run/lute trace print the same fault. The runaway-step guard still counts steps since the range started or since the last taken bounded jump.
  • Component labels. A ::label inside a component expansion is named per ::use instance in the compiled stream, and that instance’s jumps target its own labels, so a component used twice loops (or jumps forward) inside each instance (#207).
  • No snapshot change. Counters are walk-local and rebuilt by the same replay that rebuilds a hub’s presentation. The runtime Snapshot and the event schema keep their shape; only the version string moves to 0.51. A <branch> reached again in a later iteration is a new awaitChoice with a new request, and its presentation stays 0; a <hub> inside a loop starts each visit at presentation 0.
  • Trace. The driver’s jump observation gains the take number and max, and an exhausted observation when the jump is not taken. lute trace prints them as explanation, not as a static claim.

Declared raises. A plugin command gains an optional resolved raises: [{ occasion, target?, dynamic? }] and RewardEntry gains the same raises, each omitted when empty and written last (RewardEntry.raises after credits, retracts, asserts and accepts). @attr is bound to the attribute’s compiled value (else its declared default:) and the occasion’s domain prefix is applied (scene.chapter2); an unbound target writes no entry. A target that reads occasion.target is written as the reference with the domain prefix (scene.occasion.target) and marked "dynamic": true; the runtime binds only a marked raise, and refuses at load a marked raise whose target is not occasion.target with the occasion’s declared prefix, that has no target, or that is on an untargeted occasion. A reward’s raises are stamped by the reward kind’s effects.raises, with @target bound as for accepts; they are never dynamic.

  • The emitted plugin record (also the halted variant) and the grant record carry raises, with occasion.target bound and the dynamic mark dropped, omitted when empty. The record payload is untyped in the event schema, so only the record catalogue changes. The grant’s effect records (retract, assert, accept with their grant key) are unchanged, and a raise gets no record of its own, because the runtime raises nothing. lute run’s conformance records keep raises.
  • Descriptive, no semantic id. The runtime does not act on raises, and plugin vocabulary is versioned by capabilitySnapshot. A reward whose kind declares only raises keeps lute.quest.rewardEffects/1 out of requiredSemantics: that id is still collected only for retracts, asserts and accepts.
  • Host duty. A host that performs raises sends each one as a raiseOccasion input after the presentation that ran the call ends (presentationEnd) and the runtime is idle, or after the output carrying the grant record has been applied. It keeps its own pending queue across saves, because the runtime replays only the undelivered suffix of an output (see Custom hosts).

Localized directive text. A plugin command gains an optional localized map, keyed by attribute name ({ "<attr>": { "lineId", "placeholders"?, "texts"? } }), for each attribute typed text; lineId is {prefix}~{tag}~{code}~{attr}, and placeholders and texts are omitted when empty. fields.<attr> keeps the source-language string with its {{…}} markers as authored, so a host that ignores localization reads what it read before; the 0.50 stamps of the call stay in stamps. texts (locale tag → translated text) is filled by lute compile --locale-bundle, as for a line. A directive without text attributes is byte-identical to 0.50.

  • Runtime. For a plugin command with localized, the runtime renders each text attribute’s markers once and writes the rendered source into the record’s attrs.<attr>, and the rendered translations into the record’s localized: { <attr>: { lineId, texts } }, as it does for a line. Host-token markers stay verbatim. The bridge await’s attrs carry the same rendered values. lute run’s conformance records drop localized, as they drop a line’s translations.
  • Descriptive, no semantic id. Engines MAY read raises and localized.

What an engine or host must do. Widen the version gate to 0.51; list lute.core.boundedJump/1 in supportedIds to accept an artifact with a bounded loop; optionally perform declared raises and read localized. The capabilitySnapshot of every artifact moves once, so an engine that pins a snapshot hash updates it. See Localized text for the texts model.

Every 0.50 IR change is additive: a project that uses none of the new keys compiles to byte-identical artifacts (capabilitySnapshot included). The contract schemas are renamed to the -0.50 line.

Stamps on branch, hub and choice. A plugin stampAttrs entry whose on: names branch, hub or choice can be authored on that element. The choice/hub command record’s existing stamps object now holds the <branch>/<hub> element’s stamps. Each choice/hub option gains an optional stamps object ({ "<attr>": <value> }, filled from the <choice> element’s stamps, serialized last, omitted when empty), so an option without stamps is byte-identical to 0.49. An unauthored stamp is never injected, and a stamp is typed by its surface’s declaration (one name can serialize with a different type on two surfaces). Stamps are descriptive: the runtime never reads them, the awaitChoice menu is unchanged (it carries the address document/position, verdicts and once/exit), and there is no semantic id. A host that does not use the reference player reads element and option stamps from the IR record at the menu’s address, as it reads option text. The reference player’s ChoiceView and each option carry an optional stamps read from that record, omitted when absent.

Host tokens stay verbatim. A bare {{token}} that a plugin declares in its hostTokens export is a reserved placeholder, { "kind": "reserved", "token": "<name>", "format"? }, exactly as userName always was (lute.core declares userName). There is no new environment entry. The runtime renders every host-token marker verbatim in the output text, and the host or presenter substitutes it at render time; this holds for every declared token, not only userName. Player behaviour is unchanged.

Reward effects. RewardEntry gains optional resolved retracts and asserts (each a list of { relation, args } fact records, the shape a plugin command’s facts carry) and accepts (a list of quest ids), each omitted when empty and written after credits. @target is already bound; a pattern left with an unbound @target is not written. A reward kind declares these with rewardKinds.<K>.effects; a kind without effects produces byte-identical entries.

At the grant instant, for a reward that is granted (its outcome filter matches the transition and its when= holds), in declaration order and per reward: the grant record and its credits write, as before; then the reward’s retracts, then asserts, through the runtime’s ordinary assert/retract path (including the E-FACT-EXCLUSIVE runtime check); then its accepts, through ::accept’s path. An accept-driven quest of the artifact activates on the next lifecycle round, and a quest already past unset is left alone, with the record saying so. Objective grants precede quest grants, and all grants of a transition precede its lifecycle handler body, so a questComplete handler reads the asserted fact. An effect fires exactly once per grant, and replay and restore follow the grant’s existing instance identity.

The grant key on effect records. Each effect emits the record its command would, plus "grant": { "quest", "instance", "index", "objective"?, "kind" }, the identity of the grant record it follows. It has no position and no effectOf. A host lists the grant key on assert, retract and accept records:

{"kind":"grant","quest":"first","instance":1,"index":0,"reward":{"kind":"BADGE","target":"mira","amount":1}}
{"kind":"assert","fact":"met(mira)","grant":{"quest":"first","instance":1,"index":0,"kind":"BADGE"}}
{"kind":"accept","quest":"second","grant":{"quest":"first","instance":1,"index":1,"kind":"OFFER_QUEST"}}

The event and snapshot shapes do not change: the record payload is untyped in the event schema, so only the record catalogue grows, and facts and accepted quests are already saved in snapshots. The player treats assert/retract/accept as bookkeeping cues.

New semantic id lute.quest.rewardEffects/1 (module quest, behaviour “reward fact and accept effects”). An artifact lists it in requiredSemantics (provenance reward effects) only when some RewardEntry has a non-empty retracts, asserts or accepts. An engine that ignores these fields would lose facts and quest activations, so the id is meaning-changing: an engine that does not list it in supportedIds refuses such an artifact at semantic negotiation, before playback. The registry goes from 15 to 16 ids, and an artifact that uses no reward effect negotiates exactly as before.

Plugin stamps are one nested stamps object. A cross-cutting plugin stampAttrs value an author wrote on a directive or content line is no longer flattened onto the record. Every stamped command (all but jump and barrier) carries "stamps": { "<attr>": <value>, … }, after timing, provenance and source; the object is omitted when no stamp was authored, so a record without one is byte-identical to 0.48.1. A stamp named like a record key (tag, speaker, text, label, …) can therefore no longer collide with it. The core stamp members timing, provenance and source stay flattened, and the eight reserved names are unchanged.

The same object reaches the host on every plugin path, separate from attrs and never merged into it: a normal and a halted plugin event record, awaitBridge, the bridge request, and the suspended snapshot that carries it each have an optional stamps, omitted when empty. Staging records keep their core-field attrs; stamps are not mixed in.

timing.wait on a content line. A line admits wait (a boolean). It compiles to the line record’s timing.wait and is omitted when not authored. A line always waits for its own advance; wait says whether the staging cues after it, up to the next line, wait for the line. true or absent means they wait (unchanged). false means they start while the line is presented. The next line still waits for the previous one. The reference player honours this: a line cue with timing.wait === false is forked, and the scheduler joins it before the next line cue, before a presentation cue (it clears the dialogue window, so it must not overlap the line) and before the batch settles. delay and duration on a line remain E-UNKNOWN-ATTR.

Lute has no free-text construct. The host half of “ask the player to type something” is a bridge whose result is a string, written into a state slot:

bridge/prompt.yaml
bridgeCapabilities:
- service: prompt
operation: ask
replay: recorded
result:
- { name: answer, type: string }
directives/ask.yaml
directives:
- name: ask
layer: bridge
attrs:
- { name: slot, required: true, type: { slotId: { namespace: scene.ask } } }
semantics: [ "writes.sceneState", "bridgeCall" ]
state:
declares:
- { scope: scene, path: [ask, { fromAttr: { name: slot, slotType: localId } }], shape: textAnswer }
effects:
writes:
- { scope: scene, path: [ask, { fromAttr: { name: slot } }, answer], value: { fromBridgeResult: answer } }
bridge: { service: prompt, operation: ask }

The runtime suspends with awaitBridge. The host opens its own text prompt from the call’s attrs (here slot), and answers bridgeResult { request, fields: { "answer": "<what the player typed>" } }. The runtime validates the string and applies the declared write, so the lines after the call read the answer through ordinary state. Lute never sees the input channel, and the answer is data like any other bridge result. The complete project, with its .test.yaml, is docs/examples/free-text-input/.

Writes that belong to the ending. Once the runtime answers ended, it accepts only newRun or an outsideRun raise. A host write that belongs to the ending run — ending metadata, a final score — rides as writes on the input that causes the terminal transition, when that input carries writes: raiseOccasion, advanceClock, bridgeResult or hostWrite, whose writes land before the transition ({"type": "raiseOccasion", "occasion": "dawn", "writes": {"state": [{"path": "run.endedBy", "value": "dawnSeven"}]}}). choose, timeout and worldEvent carry no writes: an ending they cause records its state in the story (a ::set in the branch that ends) or in a hostWrite sent before them. newRun’s writes seed the run it starts, where the ended run’s run.* values read as prev.run.*. A hostWrite after the ending is still refused E-RUNTIME-BUSY with the state unchanged; its message now says where the write belongs.

Read-only queries (candidates, evaluate, def, gate, and view) answer at every await (idle, awaitChoice, awaitBridge, and ended) without changing state. On a TypeScript Session, a query during a running input reads the last committed state. While a choice or bridge is pending, only its answer is accepted. A host MAY show a background bark outside the presentation by querying candidates(occasion, target?), taking an eligible entry, locating its artifact body, and showing it only when every record is a placeholder-free line. This static bark records nothing (no read mark, once spending, or writes); a recording or dynamic bark MUST be raised after the story returns to idle. Concurrent presentations are not planned. See the 0.45 background-bark host pattern and the full event contract.

Every staging record — bg, music, sfx, vfx, actor, camera, cg, video and sequence — carries attrs: the command’s resolved fields, as a plugin record’s attrs already do. Authored values and the defaults lowering filled in are present (a cg’s display: "show", an actor’s declared default anchor); an absent optional field is omitted; attrs is always an object. The record envelope (kind, position), timing, provenance and the nested plugin stamps stay where they were (stamps are not mixed into attrs), so a host reads a staging record without looking its command up in the artifact. volume stays a member of the project’s volume domain; the player turns it into a gain through volumes.

kindattrs
bglocation?, time?, assetId?
musicplayback?, stop?, mood?, volume?, assetId?
sfxsound?, assetId?
vfxtype, label?, transition?
actorcharacter, anchor?, action?, exit?, emotion?, costume?, posReset?, preload?
camerafocus?, framing?, move?, transition?
cgassetId, display, layout?
videoassetId, display
sequencename
  • Gate refusals are rejections. A raiseOccasion whose raisedWhen is false, or raised after terminal holds when the occasion is not outsideRun, is rejected E-OCCASION-GATE with the state unchanged (the input’s own writes and payload included); the message is lute play’s refusal text. An undecided gate still halts incomplete. The read-only gate(occasion, target?) query answers { open, unresolved? } beforehand.
  • def(name, args) evaluates an index def with parameters: one JSON scalar per declared parameter, bound to its param nodes; arity and type misfits are E-RUNTIME-INPUT. evaluate never admits a param node.
  • awaitBridge carries attrs (the call’s attributes, occasion.target bound) and results (each result field content reads with its declared type); answers are validated against those types. bridgeResult may carry writes (as hostWrite’s), applied at the call after its declared effects, so later lines of the same presentation read them. Every plugin record carries attrs; non-blocking plugin records are write-free.
  • advanceClock { writes } applies and settles its writes before the clock moves (if terminal then holds, the clock stays and the await is ended); the move’s raises read them, and the clock event comes last.

The 0.40 contract carries the persistent presentation stage (background, music, actors, CG and video) in world.stage snapshots and exposes stage() as a read-only query. A suspended choice or bridge also carries its await-time stage in continuation.stage; restore a snapshot and use this value to rebuild the presenter rather than replaying records.

Timed branches populate awaitChoice.menu.timeout and menu.default. The host measures wall-clock time and sends {"type":"timeout","request":N}. The runtime accepts that input only for the matching timed choice and applies the declared default option. A timeout on any other await is rejected as E-RUNTIME-TIMEOUT; a wrong request remains E-RUNTIME-REQUEST. Timed branches must declare default together with timeout.

Lute (compile time)Engine (runtime)
Statically check the document; refuse to emit on any error.Trust the artifact — it compiled clean.
Fold the state schema into an init/type table.Initialize state from that table; own the tier lifetimes.
Lower every CEL guard whose text is inside the closed §8.4 profile to a portable expr AST; leave the rest as CEL text in cel.Evaluate guards against live state.
Emit facts, assert/retract deltas, and Datalog rules as data; prove the rules are stratified and safe.Compute the minimal model (least fixpoint) over the fact store.
Emit quests, objectives, and <on> handlers as declarations.Derive the quest lifecycle from start/fail/objective completion.
Resolve plugin bridge calls and their state-write bindings.Make the call and apply the effects.
Schedule timeline clips and prove no write races.Replay the schedule (or run tracks concurrently) and honor the barrier.

holds() and count() are inside the §8.4 CEL profile that authors may write and are deliberately absent from the expr AST, so a guard that queries facts reaches the engine as its cel text alone (option.when, arm.test, set.value) with no expr sibling — and an engine MUST therefore have a CEL evaluator, not merely an AST walker. lute run’s module doc says the same: it resolves every slot from the cel text “including the holds/count fact-query functions the structured expr AST deliberately omits”. The same holds for visited('<scene id>') (dsl 0.21.0): legal in every condition slot, true once that scene has been presented in this save — the visited set the engine already keeps for after: — and carried as cel text alone.

Component scopes use the authored instance key: ::use{component="hearthFire" instance="opening"} produces hearthFire#opening, never an ordinal. A project that publishes joins or saves opts into identity.requireStable: true; this enables diagnostics for missing component instances and line codes. lute tag back-fills both, preserving existing authored keys and allocating use-001, use-002, ….

The optional identityRenames array in the execution IR and project index is the engine’s only rename migration input. Each item is {from, to} using canonical NodeKey strings. lute diff reports renamed for a ledger match and unmappedIdentity when no authored mapping exists; it never guesses from text or position.

---
kind: scene
id: runtime-identity
---
## Opening
@narrator{code="intro"}: Ready.

The through-line: Lute proves shape and structure; the engine supplies evaluation and effect. Lute’s static analyses are also honest about their limits — reachability is conservative under the declared after: routes, relational fact gates yield Unknown verdicts behind a human-review boundary, and lute trace walks one deterministic mock-driven path, never a proof of all paths.

Every artifact opens with a fixed envelope (the ExecutionIr struct in crates/lute-compile/src/ir.rs):

fieldmeaning
kind"scene" | "quest" — read first; selects meta’s shape.
lutelanguage-version pin (informational for the runtime).
irVersionthe IR schema version you gate on first.
capabilitySnapshotthe plugin capability snapshot hash; refuse a mismatch.
requiredSemanticscompiler-derived, sorted semantic ids used by the artifact; authors cannot edit this list.
metascene meta or quest meta.
statethe folded init/type table.
entities / enums / relations / seedFacts / rulesthe declared vocabulary (omitted when empty).
commandsthe flat, ordered command stream; every record carries kind, family, and position.
prereqEdgesadvisory raw graph edges (omitted when empty).
sectionsthe document’s ## sections, each { section, heading, id? } (omitted when none has a heading or id).
seasons / gates / terminal / terminalPersistsoptional clock and occasion controls.

requiredSemantics is the sorted union of the lowered features in one artifact. The project index carries the same field as the sorted union of its document artifacts, allowing whole-project check, play, and an engine to negotiate before opening every document. The complete registry and trigger mapping live in the 0.33.0 proposal §2–§3; this page intentionally does not duplicate that registry.

Every command record has three required fields and four optional siblings:

fieldmeaning
kindthe record kind; an unknown kind is a hard error.
familythe normative grouping of kind (below), read by dispatch before kind.
positionthe record’s build-local execution position (see Positions).
timingoptional { wait?, duration?, delay?, at?, timeline? }; omitted when every member is absent. A line may carry wait (see 0.49).
provenanceoptional { by, explanation } on a record the compiler synthesized.
sourceoptional source location.
stampsoptional object of plugin stampAttrs values ({ "<attr>": <value> }), on every stamped command; omitted when empty. A jump and a barrier carry none. Since 0.50 a choice or hub record’s stamps holds the <branch>/<hub> element’s stamps, and each of its options carries its own (see 0.50).
familykinds
contentline
stagingbg, music, sfx, vfx, actor, camera, cg, video, sequence
stateset, assert, retract
controlchoice, match, hub, jump, end, barrier
declarationquest, on, entry, accept, beat
pluginplugin

timing.wait is a boolean; duration, delay, and at are finite, non-negative seconds; timeline is the zero-based ordinal of the <timeline> the record was emitted from — an ordinal, not seconds. A barrier has no timing: its timeline ordinal and at seconds are direct fields. Read timing only from timing.

Staging values are opaque members of the project’s declared domains: actor.anchor/action/emotion/costume, camera.framing/move/ transition (plus focus, a cast reference), cg.layout, music.playback, and sequence.name. Lute synthesizes no numeric camera transform. cg and video always carry the resolved display (show by default). A sequence record names an external cinematic; with timing.wait: true the engine finishes presenting it before advancing. An actor exit is the derived actor.exit: true (from the action domain’s exits:); it is omitted when false.

A line carries role (dialogue, narration, mono, os, or vo), speaker, text, lineId, and a voiceKey on every role — a join key, not a promise that a recording exists. text is the plain derivation of the authored text: inline modifiers removed, escapes decoded, {{…}} markers kept, one placeholders entry per marker, in order — except that (lute 0.47.1) a literal {{ (authored \{{) is written \{{ and every literal backslash directly before a {{ is doubled; segment runs, texts and localeSegments use the same encoding. Read it left to right: a run of n backslashes directly before {{ stands for ⌊n/2⌋ backslashes, and the {{ is literal text when n is odd (consumed as one unit) or opens the next marker when n is even. Lit \{{run.a}} and {{ run.a }}. with run.a = 3 renders Lit {{run.a}} and 3.. An engine that substitutes placeholders itself must apply this reading; a line with \{{ gets a new loc sourceHash, so re-export it (a 0.47.0 export of it is W-L10N-STALE). A line that uses an inline modifier also carries segments, the presentation runs: { text, styles?, rate? } text runs and { pause } leaves (seconds). A line without modifiers has no segments. See Localized text for the locale forms.

{
"kind": "music",
"family": "staging",
"position": "001-0400",
"playback": "play",
"assetId": "theme",
"timing": { "duration": 1.5 }
}

An engine evaluates each CEL slot against nested maps built from live state:

  • every declared root and intermediate map on the path to a declared slot is present;
  • a non-reserved slot is present only with an effective value (write, seed, or default), and values retain their declared CEL types;
  • reserved engine paths are always present with reserved defaults: for every quest the IR declares, quest.<id>.state and quest.<id>.failedBy are "unset"; each objective’s quest.<id>.objectives.<oid>.done and .failed are false; and every lore entry’s entry.<id>.read and .everRead are false.

Use has(path) for presence. A <match> arm with is is satisfied for a bare path with no effective value only when its value is exactly "unset"; other is values are definitely false. Other arms evaluate normally, and an evaluation error means the arm is not satisfied.

One artifact is produced per document. A project’s engine unions the relations / rules / seedFacts / entities / enums / prereqEdges across every artifact it loads, exactly as it concatenates the command streams.

enums is the one field whose content moved at language 0.9.0 while the schema stood still. It has always carried the domains an author declares in an enums: block; since content-vocabulary members became the project’s to declare, those domains — emotion, action, anchor, mood, volume, musicPlayback, vfxType — arrive through the same array. A project declaring them inline or through uses:/extends: emits them; a project whose members come from a plugin’s enums export emits no enums at all, because a plugin vocabulary is capability surface (folded into capabilitySnapshot), not per-document data. Either way this is data an engine already unions, so nothing new is required of it — the enums move added, renamed, and moved no field. That move did not change the IR shape. (The unrelated meta.plugin key arrived in the same release; see the history entry What IR 0.10.2 changed.) Members carrying compiler semantics (action’s exits:, anchor’s default:) are resolved away at compile time and never serialized: an engine needs no member semantics at runtime.

Version negotiation and engine integration

Section titled “Version negotiation and engine integration”

Every host negotiates in this order, before opening a playback session:

  1. Exact MAJOR.MINOR IR gate. Before 1.0, every minor may break the execution-IR shape, so an engine pins the exact irVersion major.minor line: a 0.53 engine accepts only 0.53.* artifacts and refuses 0.52.* and 0.54.*; patch handling is the engine’s policy. From 1.0 onward, the released major’s policy applies.
  2. Semantic capability gate. Load the immutable lute.engine.yaml matrix and compare every artifact requiredSemantics id with supportedIds. engine, irVersion, and unique supportedIds are required; version and description are descriptive. The full registry is in the 0.33.0 proposal §2.

The matrix example is:

engine: chat-text-engine
irVersion: "0.53.0"
supportedIds:
- lute.core/1
- lute.quest.lifecycle/1
- lute.quest.rewards/1
- lute.knowledge.facts/1
- lute.knowledge.rules/1
- lute.knowledge.temporal/1
version: "1.4.0"
description: "Text-only chat client: no staging, timeline, or clock"

lute run --engine <file> and lute play --engine <file> perform both gates. If an id is missing, they refuse with exit code 2 and E-ENGINE-SEMANTICS before playback: no command, condition, asset, or bridge executes. Malformed matrices use E-ENGINE-MATRIX; an artifact outside the matrix’s exact major.minor line (a 0.41.* artifact against the 0.53.0 matrix above, say) uses E-ENGINE-IR-VERSION. Unknown artifact or registry ids use E-SEMANTICS-UNKNOWN. lute run refuses an

artifact that still carries a removed 0.36 field with E-IR-REMOVED-FIELD. lute check --engine <file> and lute check --project DIR --engine <file> perform author-time negotiation without playback. Missing capabilities are reported as E-CHECK-ENGINE-SEMANTICS with the lowered construct’s source span, semantic id, and engine name. The project form checks the project-index union and lists each contributing document/span. Plugin compatibility remains the exact capabilitySnapshot check and is not folded into this matrix.

The reference executor’s built-in matrix is named reference and supports every current semantic id.

Match-arm shorthand semantics (dsl 0.32.0 §7)

Section titled “Match-arm shorthand semantics (dsl 0.32.0 §7)”

Each match arm has test and target; an arm authored with <when is="…"> also has semantic IR field is containing the shorthand text. A test= arm omits is. The slot’s authored field is diagnostic-only and MUST NOT be used to infer whether an arm came from shorthand.

When the match subject is a bare state path with no effective value, an arm with is other than "unset" is definitely not satisfied; is: "unset" is satisfied. Other arms are evaluated normally, and an evaluation error means not satisfied.

The index shape is generated from the Rust index types: the projectIndex, indexDocument, indexEntry, indexBeat, indexOccasion, indexStateDomain, indexDef and fieldType definitions of the IR schema come from lute-contract-schema, and refer to the artifact shapes (enumEntry, celSlot, exprNode, clockDecl, …) instead of repeating them. Three keys change (spec 0.41.0 §4):

  • bridgeResults: each bridge result field carries its declared type in the occasions[*].payload form — "bool", "int", "double", "string", {"enum": [...]}, {"domain": "<name>"}, … — instead of "number"/"string".
  • enums[].labels: { <member>: "<text>" }: the long-form labels: of the merged enums: declaration, on index and artifact rows alike, omitted when none is declared.
  • defs: one row per plugin or uses: schema def, keyed by name — { cel, expr, authored?, params?, host? }. params lists [{ name, type }] in argument order, each parameter appears in expr as {"param": "<name>"}, and host: true marks a def the host reads. Defs with a non-scalar parameter, a body that does not lower, or different bodies or parameter lists in two documents are left out. A param node appears nowhere else.

Since 0.47 the index also carries an optional contentLang: the manifest’s defaults.contentLang, the source language of the project’s text (omitted when the manifest declares none). A player uses it as the source locale of a loaded bundle, before a document’s meta.contentLang.

A clean surface cutover: renamed kinds and fields, one new kind, removed fields. An engine on the 0.36 line must refuse a 0.37 artifact, and nothing old is accepted as an alias. To move an engine to 0.37 (dsl 0.37.0 §2.2, §5):

  • Envelope: capabilityVersion is capabilitySnapshot (the project index and lute context --json use the same key); shots is sections, each { section, heading, id? }, where id is the authored ## Heading {#id} suffix.
  • Every record carries the new required family (see Command records); the common addr is position, with the same format; the flattened timing fields move into one timing object; and provenance.injected is removed.
  • Kinds: background is bg, sprite is actor, cut is cg, and sequence is new. music.action is music.playback; cg.action and video.action are display; cg.full is the domain-valued layout; vfx.vfxType is type; music.track and sfx.name are removed. Camera’s numeric zoom/moveX/moveY/shake/reset/easing are replaced by the domain-valued framing, move, and transition.
  • Lines: roles monologue, offscreen, and voiceover are mono, os, and vo; every line carries voiceKey; a line with inline modifiers adds segments, and its translations add localeSegments.
  • Control: choice and hub option label is text (its locale map is texts); recordKey is selectionKey.

The schema file is lute-ir-0.37.schema.json.

History, retained for anyone migrating an engine from an older line. Each entry below describes the gate rules of its own release (some predate the exact major.minor gate); a current engine follows Version negotiation above.

No field added, renamed or retyped; names may now carry - or open with a digit. An engine that loads 0.29 artifacts loads 0.30 ones, provided it reads paths and conditions as follows (dsl 0.30.0 §3):

  • Path fields are canonical dotted strings. A set command’s path, a state entry’s path, an expr node’s path / isSet / has and a {{…}} placeholder’s path join names with .: quest.zero-coke-001.state, run.visits.lab-b2, run.visits.001. A segment may contain - or start with a digit but never contains ., so split('.') recovers it — key state on that string or on its segments, never re-parse it as CEL.
  • Conditions ship as written. A condition’s raw is the author’s CEL, and a name that is not an identifier is reached with a quoted index, as in JavaScript: quest["zero-coke-001"].state, run.visits['lab-b2'] >= 2. That is ordinary CEL over nested maps — m["k"] and m.k read the same entry — so an engine evaluating raw over its state maps needs nothing new. The portable expr of such a condition carries the canonical path ({"path": "run.visits.lab-b2"}).
  • Fact arguments are names. holds('at', ["lab-b2"]) in raw asks about the fact an assert command writes as {"relation": "at", "args": ["lab-b2"]}; the quotes are the condition’s spelling, never part of the name. Seed facts and the structured terms of rule heads and bodies carry the bare name the same way.

The schema file renames per release line: lute-ir-0.29.schema.json is now lute-ir-0.30.schema.json, and its name patterns (seasons, occasions, share keys, series, document ids) accept a name with - or a leading digit.

One optional field; nothing renamed. There is no new command kind and no field is renamed, retyped or removed, so under the MAJOR-only gate an engine that loads 0.28 artifacts loads 0.29 ones unchanged (dsl 0.29.0 §5):

  • terminalPersists: true on the artifact and project.index.json, serialized after terminal, when every terminal: declaration is the long form with persists: true. The ending outlives runs on purpose (a roguelike’s permanent ending): once terminal holds, raise no occasion in this run or any later one — a new run does not reopen the game (a condition that may persist reads state a new run keeps, so it still holds). Omitted when false.

The schema file renames per release line: lute-ir-0.28.schema.json is now lute-ir-0.29.schema.json.

Three quest-layer fields renamed; everything else additive. There is no new command kind and no field is retyped or removed, so under the MAJOR-only gate an engine that loads 0.27 artifacts loads 0.28 ones — but one that reads the renamed fields must read them under their new names (dsl 0.28.0 §6):

  • ObjectiveEntry.visibleWhen (was when): visibility only — show the objective while it holds; it never gates done.
  • RewardEntry.outcome (was on): only ever "failed", on a quest-level reward that grants on a fresh failed transition.
  • A quest’s prereqEdges row carries follows (was after): quest-graph metadata that never gates the quest (its start does) and no in-script accept; on an accept="external" quest it is the condition under which the host grants it (the host applies it; Lute does not enforce it). Scene and bundle-beat rows keep after.

New behaviour and optional fields, each omitted when unauthored:

  • outsideRun: [occasion] on the artifact and project.index.json, name-sorted: raise these occasions even after terminal holds (a title screen, a gallery between runs); every other occasion stays closed once the game is over.
  • HubCmd.return: the position of the hub’s <return> segment. Run it each time a non-exit option’s segment ends, before the hub is judged and presented again — never before the first menu and never after an exit option. Like an option target, it bounds the segment before it.
  • clock.raiseAtStart: true: raise the clock’s slot occasion (the string form, or the map’s slot) and the map’s dayStart yourself at the run’s first position, where no advance stops. Omitted when false.
  • The reserved clock reads clock.day and clock.slot (aliases of the clock’s day and slot paths) and, on a finite clock, clock.ended: false until the advance that ends the clock, true from that advance’s settle until a new run starts a run-tier clock over.
  • labelForms: { <member>: { start?, indefinite? } } on entities[] and state[] entries, and the placeholder formats cardinalWord (one … twenty, digits above), capitalize, start and indefinite, also on reserved and occasionTarget placeholders. capitalize upper-cases the rendered text’s first letter; start renders the member’s start form, else capitalizes; indefinite renders its indefinite form, else a / an by the first letter, a space and the text. In a plural form, #word / #Word is the number as a (capitalized) cardinal word.
  • Relation tier: "season:<name>": the relation’s facts go back to the seed facts each time that season opens.
  • authored beside cel on a seam condition (gates[].raisedWhen, terminal, seasons[].live) when @def expansion changed it: for messages only; evaluate cel.
  • spentBy latches: the once of a spentBy beat is now its period (run unless written), not "none" — never spend such a beat on presentation (see spentBy below).
  • A forKind beat spends once per member: each member’s presentation counts separately, and a for entry’s first-read writes apply on each member’s first read in the run.
  • occasion.target writes: see Writes through occasion.target below.
  • When the seam is decided: judge terminal and an occasion’s raisedWhen when the occasion is raised; a judge: before judgement that makes terminal hold does not close that occasion’s own beats. A select: sequence raise judges each beat again just before its turn, once an earlier beat of the raise has played.

Additive fields and new values of existing fields. No field is renamed, retyped, or removed, and there is no new command kind, so under the MAJOR-only gate an engine that loads 0.26 artifacts loads 0.27 ones. Cadence (dsl 0.27.0 §5):

  • once: "week" on BeatIr (a scene’s meta.beat), BeatCmd, EntryCmd and project.index.json beat rows: spent from its presentation until the next clock week starts. Week n holds the days whose (day - 1) div week.length is n, so a week starts when clock.weekday returns to week.first. Only emitted when the clock declares a week:.
  • once: "season:<name>" on the same records: spent from its presentation until the season <name> opens again.
  • spentBy: {cel, expr, authored?} on BeatIr, BeatCmd and EntryCmd (its raw alone on the index beat row): the beat is spent by this condition instead of by being presented. Observe it at every quest settle (per member of a kind or for beat, occasion.target bound) and when the beat is judged: once it has held, the beat is spent for its once period — run when once is run or absent (a new run clears it), user never, day / slot / week while the clock stays in the period it was first seen holding in, season:<name> until that season opens again — even if the condition turns false. A presentation of a spentBy beat spends nothing. Omitted when unauthored.
  • QuestCmd.rearm: {cel, expr, authored?}: observe it at every quest settle of a playthrough, the first observation being the baseline. Each time it goes false→true, return the quest to unset (objectives not done, failedBy cleared, deadlines forgotten); a start that holds activates it in the same settle. Omitted when unauthored.
  • QuestCmd.tier: "season:<name>": return the quest to unset when the season opens again, as a "run" quest at a new run.
  • seasons: [{ name, live: {cel, expr, authored?} }] on the artifact (after clock, gates, terminal) and on project.index.json, with live after @def expansion. When a season’s live goes false→true, copy every season.<name>.* value into prev.season.<name>.*, reset season.<name>.* to its declared defaults, clear the presentation record of its once: "season:<name>" beats, and return its season-tier quests to unset. Seasons are independent and may overlap. prev.season.* is a read-only mirror, like prev.run.*.

Text (dsl 0.27.0 §7):

  • entities[].labels: { <member>: "<text>" }: display text for a kind’s members, with a sub-kind’s and its ancestors’ labels for shared members. Render an occasionTarget placeholder of entityKind K as labels[member] of K, else the cast name: when the member is a cast id, else the id. A { domain: K } or { entity: K } state path carries the same map as its state[].labels, which path placeholders already render. Omitted when none.
  • Placeholder format: "plural" with forms: [one, other]: render one when the number is 1, other otherwise, each # in the form replaced by the number ({{n:plural(# lamp|# lamps)}} → 3 lamps). A localizing engine may choose its own plural categories from the two English forms.

The finite clock (dsl 0.27.0 §4):

  • clock.last: { day, slot? } or clock.days: N, verbatim as declared (days: N is last: { day: N }; slot omitted means that day’s last slot). The clock stops at that position: an advance whose destination lies past it moves only to the last position (raising dayEnd / dayStart at every midnight it crosses on the way), raises the last day’s dayEnd once and no slot occasion, and the clock is ended — advance no further until a new run resets the day and slot paths. Both omitted on a clock that never ends.

The engine seam (dsl 0.27.0 §4):

  • gates: [{ occasion, raisedWhen: { cel, expr, authored? } }] (top level, occasion-sorted, @defs expanded): raise occasion only while its gate holds, reading occasion.target as the member it is raised for (room.office → office). A raise your clock makes (raise.slot, dayStart, dayEnd) whose gate is false is simply not made; the clock still moves. The checker has judged every beat of the occasion under its gate, so a beat you would never present is reported to the author. Omitted when no occasion declares a gate.
  • terminal: { cel, expr, authored? } (top level): the game is over once it holds — raise no occasion and advance no clock until a new run. Several schemas’ declarations arrive joined by ||. Omitted without one.
  • Directive fact effects: a kind: "plugin" record may carry retracts and asserts, each [{ relation, args }] with the call’s attributes already substituted (_ in a retract matches anything). Apply them after the record’s own writes, retracts first, through your ordinary assert/retract path (a reserved relation included: the write is the engine’s own). Omitted when empty. A lore entry’s effect-only directive applies them on the entry’s first read only, like the entry’s set records.

Members bound by occasions (dsl 0.27.0 §3):

  • occasion.target as a pattern argument and a family index: in a kind beat’s (and a forKind beat’s) CEL, a fact-query pattern may take occasion.target as an argument (holds('owned', [occasion.target])) and a per: family may be indexed by it (user.bond[occasion.target]). Substitute the bound member: the pattern argument becomes that member id, and F[occasion.target] reads F.<member>. The checker has proved every member’s instance well-typed.
  • Writes through occasion.target (dsl 0.28.0 §3): in the same beats, a set record’s path may end in [occasion.target] (run.count[occasion.target]: write run.count.<member>), an assert / retract argument may be occasion.target, and so may a plugin record’s field value, effect path index (effects[].path) or fact argument (asserts / retracts). Substitute the bound member before applying the write, exactly as for a read. A component argument never reaches you this way: it is compiled to one match arm on occasion.target per member.
  • forKind: { kind, members } on BeatIr (a scene’s meta.beat), BeatCmd, EntryCmd and project.index.json beat rows: the beat answers an untargeted select: sequence occasion once per listed member, in list order. Judge each member’s eligibility at the raise with occasion.target bound to it, and present the beat once for each eligible member, binding occasion.target while it runs. once spends the beat, not a member. Omitted when unauthored.
  • Occasion payloads: an occasion declared with payload: { <field>: <type> } hands typed values to each raise. Bind occasion.payload.<field> to the raise’s values before its beats are judged, and unbind them once its beats have been presented: a payload lasts one raise. The artifact’s CEL and path placeholders read them like state (occasion.payload.copies).

Two additive fields, a new reserved flag, a wider occasion declaration, and new identity values. No field is renamed, retyped, or removed, and there is no new command kind, so under the MAJOR-only gate an engine that loads 0.21 artifacts loads 0.22 ones. An engine that ignores the new fields plays them with 0.21 semantics: every quest persists across runs and every entry beat repeats. To play them as written:

  • QuestCmd.tier is "run" on a <quest tier="run"> and omitted for the default user tier. It rides on each quest record, not on meta, because one quest document may declare several quests. When a run starts, return every run-tier quest to unset with its objectives not done, then settle the lifecycle as at any evaluation instant. User-tier quests keep their status. See quest-lifecycle.md.
  • EntryCmd.once is "run" or "user" on an entry beat that authored it, and omitted otherwise (repeatable, as in 0.21). ProjectIndex.beats entry rows carry the same value; scene rows always carried once. A "run" entry beat is not eligible while entry.<id>.read is true, and a "user" one once entry.<id>.everRead is.
  • entry.<id>.everRead is a new reserved, user-tier bool per entry, default false. Set it whenever you set entry.<id>.read after a first read, and never reset it when a run starts. It is not listed in the artifact’s state table (entry.<id>.read is, with entry:<id> provenance), but content reads it like any path, in raw CEL and in the expr AST ({ "path": "entry.<id>.everRead" }), so keep one for every entry you load.
  • Occasion target domains. In the capability snapshot an occasion’s target is false, true, or { "prefix", "entity" }. For a domain, the checker guarantees every beat target of that occasion is <prefix>.<member> of the project’s entity kind entity, so raise the occasion with targets of that shape. Occasion declarations stay out of the artifact. A domain changes capabilitySnapshot; true and false keep their 0.21 stamp.
  • owner: engine. A state: declaration may name the engine as the path’s writer. The checker refuses a content ::set of such a path, or of a field under it (E-ENGINE-OWNED-WRITE), so no set record in a clean artifact targets it: the engine writes it. The artifact’s state entry for the path is unchanged (path, type, default); lute context --json reports the owner as "owner": "engine".
  • Identity values move. The default voiceKey template is now {prefix}.{speaker}-{code} (it was {speaker}-{code}). A line expanded from a component is minted under {prefix}.{component}#{n}, where n counts the host’s ::uses of that component from 1, so under the default templates its lineId is {prefix}.{component}#{n}.{speaker}_{code} and its voiceKey {prefix}.{component}#{n}.{speaker}-{code}; a nested ::use adds another segment. The field shapes are unchanged but the values differ, so rebuild voice and localization tables keyed on 0.21 ids. A project with audio recorded against the old keys pins identity: { voiceKey: "{speaker}-{code}" } (see Frontmatter & profiles).

One additive field — no field renamed, retyped, or removed. meta (both sceneMeta and questMeta) gains plugin: a plugin-owned, checker-validated top-level frontmatter key, folded in as { "<key>": <json value> }, skipped entirely when the document authors no plugin-owned key:

{ "meta": { "character": "marina", "season": 1, "episode": 2,
"episodeId": "s01ep02", "plugin": { "cast": { "ana": { "costume": "school" } } } } }

A key is present only when it is both declared by an active plugin and its authored value independently passes that plugin’s declared schema — the same predicate the checker already gates E-FRONTMATTER-SCHEMA on, so a value the checker would reject can never reach meta.plugin. See plugin-system 0.0.4 for the normative membership rule.

No gate widening, by the ignore-unknown-fields rule. 0.10.2 shares major minor 0.10 with 0.10.0/0.10.1, and plugin is a genuinely new object key — an engine that already accepts a 0.10.x artifact and ignores keys it does not read keeps working unchanged. If you validate strictly against the published JSON Schema rather than merely parsing, sceneMeta/questMeta in lute-ir-0.10.schema.json now admit plugin as a typed property (still additionalProperties: false otherwise); the file keeps its name because the gated major.minor did not move.

History, retained for anyone still on the 0.10.1 line. Nothing in the shape. IR 0.10.1 was shape-identical to IR 0.10.0 — no field added, renamed, moved, or retyped, no new command kind. The number moved only because a release re-aligns every visible axis; widening the gate to accept 0.10.1 cost nothing beyond what 0.10.0 already required, since both share major.minor 0.10.

History, retained for anyone still on the 0.10.0 line. One field rename — the first shape change since 0.8.0. The injection provenance stamp’s reason becomes explanation:

{ "by": "auto-pose-reset",
"explanation": "pre-loading `vesna`'s first emotion `level` seen ahead of the entrance" }

The old name was a collision, not a synonym. end.reason is an opaque author token you dispatch on — the author writes it, you branch on it. This field is human-readable English the compiler wrote to explain why a record it synthesized exists, and nothing dispatches on it. Two keys with the same name and nothing else in common is exactly the trap a renamed field removes, and explanation reads correctly beside by.

Nothing else in the shape moves: no field added, no field retyped, no new command kind, no changed constraint.

The gate above is normative, and this bump costs you two edits rather than one. An engine that implements IR 0.9 must refuse an artifact stamped 0.10.0, because 0.10 is a newer major.minor. The update is:

Widen the gate to accept 0.10, and if you read the injection provenance stamp, rename reason to explanation. Nothing after that — no new kind to dispatch, no behavioural difference.

If you validate against the JSON Schema, repoint at lute-ir-0.10.schema.json; the 0.9 file is retained beside it.

Artifact content also moves, in a way that costs nothing: clip at, duration, delay and the barrier at are still JSON numbers in seconds, but they are now computed from integer milliseconds, so a cursor-derived 1.2 stops serializing as 1.2000000000000002. And the capability snapshot changes — W-INJECT-CONFLICT left the code set and eleven codes joined it.

History, retained for anyone still on the 0.9 line. Nothing in the shape. IR 0.9.0 was shape-identical to IR 0.8.0 — no field added, renamed, moved, or retyped, no new command kind. The number moved only because Lute’s versioning policy re-aligns every visible axis number on every release, and widening the gate to accept 0.9 was the entire migration. What did move was artifact content: enums began carrying the project’s content-vocabulary domains (see the envelope) and the capability snapshot changed because the core’s vocabulary emptied — both new values in fields that already existed.

History, retained for anyone still on the 0.8 line. The schema file was renamed schemas/lute-ir-0.7.schema.json → schemas/lute-ir-0.8.schema.json along with the minor bump. Three deltas matter to a consumer:

  • end is a new command kind. By the unknown-kind rule above, an engine implementing only IR 0.7 must refuse an artifact carrying one — it cannot fall through the record, because end terminates the walk and falling through would play content the author marked unreachable. This is why termination is a core kind rather than a plugin directive: a plugin directive lowers to kind: "plugin", which an older engine would happily skip.
  • The section-heading table and the locale maps are append-only optional fields, so by the ignore-unknown-fields rule a 0.7 engine still loads a 0.8 artifact that carries no end record.
  • The position width invariant is new, and it is the one change that can alter bytes in an artifact you already consume. See below.

Every command record carries a position, a string "{section}-{(index + 1) * 100}" (e.g. "001-0300"): the one-based ## section number, then the record’s order within that section. It is regenerated on every compile — a build-local execution position, never an identity, a localization key, or a save key. The stable content joins are lineId / voiceKey; a section’s stable identity is its optional id in sections.

A section is presentation and identity, not a runtime boundary. The runtime runs a scene’s sections as one range: there is no section resume point, no section entry a host can address and no section jump target, and a bounded loop may span sections. Content a host routes to is a bridge result that the script <match>es, not a section it enters.

Both segments are zero-padded to a width computed from the document — at least 3 for the section and 4 for the index, wider when the document needs it — and that width is uniform across the whole artifact. The guarantee that follows is the one you can rely on:

Within one artifact, every emitted position has the same length; therefore lexicographic order over position equals execution order.

The fields that carry a position are exactly: every command position; choice.converge and choice.options[].target; hub.converge, hub.return, and hub.options[].target; match.converge, match.otherwise, and match.arms[].target; jump.target; quest.objectives[].body (null for an empty body); and on.body, entry.body, and beat.body.

lute compile --stream emits a full ordinary artifact snapshot for every accepted continuation unit. It does not define a second IR or a patch language. appendFrom is the previous snapshot’s command count and only identifies the newly appended array region; it is not a runtime PC and does not request execution of every record in that region.

When a snapshot arrives, replace the immutable program image and rebuild the position -> command index map. Retain the numeric command cursor, current state values, facts, selected control-flow stack, and host-owned effect/ idempotency records. Initialize only newly declared state slots. Never reapply an existing default or seed fact merely because the artifact object was replaced.

Rebuilding position lookup is required because appending commands can widen uniform position padding across the complete artifact. The compiler compares typed positions and control targets by numeric (section, index) meaning, so this formatting-only change is accepted; it rejects any semantic mutation of an already emitted command or state entry with E-STREAM-PREFIX-CHANGED.

The ordinary dispatcher still chooses branch, match, hub, and jump paths. When it reaches the current stream frontier, wait for another snapshot rather than marking the scene complete. Completion follows a successful compiler EOF finish, or an authored ordinary end command. EOF and end are distinct: end can stop runtime execution before stdin closes, while EOF never synthesizes an end.

The commands array is already in execution order. Control-flow fields — jump.target, choice/hub option target and converge, match arm target/otherwise/converge — are all positions. Walk with a program counter over a position → index map, dispatching on family and kind:

// Every CEL slot carries its verbatim source under its own key — `option.when`,
// `arm.test`, `set.value` — and the lowered `expr` AST ONLY when that CEL is
// inside the closed §8.4 profile. A relational fact query or a `visited()` read
// carries raw text alone.
const evalSlot = (raw, expr, state, facts) =>
expr !== undefined ? evalExpr(expr, state) : evalCel(raw, state, facts);
const index = new Map(artifact.commands.map((c, i) => [c.position, i]));
let pc = 0;
while (pc < artifact.commands.length) {
const cmd = artifact.commands[pc];
let next: string | null = null; // null ⇒ fall through to pc + 1
switch (cmd.kind) {
// family "content"
case "line": present(cmd, state); break; // placeholders; segments when present
// family "staging": opaque domain members; honor cmd.timing
case "bg": case "music": case "sfx": case "vfx": case "actor":
case "camera": case "cg": case "video": case "sequence":
stage(cmd, cmd.timing); break;
// family "state"
case "set": writeState(state, cmd.path, cmd.op, evalSlot(cmd.value, cmd.expr, state, facts)); break;
case "assert": facts.assert(cmd.relation, cmd.args); break;
case "retract": facts.retract(cmd.relation, cmd.args); break;
// family "control"
case "choice":
case "hub": {
const opt = pickOption(cmd, state); // per option: evalSlot(o.when, o.expr, …)
next = opt ? opt.target : cmd.converge; break;
}
case "match": {
const arm = cmd.arms.find(a => truthy(evalSlot(a.test, a.expr, state, facts)));
next = arm ? arm.target : (cmd.otherwise ?? cmd.converge); break;
}
case "jump": next = cmd.target; break;
case "end": finish(cmd.reason); return; // terminates the walk
case "barrier": joinTimeline(cmd.timeline, cmd.at); break;
// family "declaration" and family "plugin"
case "quest": registerQuest(cmd); break;
case "on": registerHandler(cmd); break;
case "accept": acceptQuest(cmd.quest); break; // activates it iff still `unset`
case "plugin": callBridgeAndApplyEffects(cmd, state); break;
default: throw new UnknownCommandKind(cmd.kind); // version gate: hard error
}
pc = next === null ? pc + 1 : index.get(next)!;
}

Since 0.51 the jump case above is the unbounded form. A jump with max keeps a per-walk counter and takes the jump only while it is below max, otherwise falling through (next = null); see 0.51 additions.

The full scene and quest command set is twenty-three kinds: line, bg, music, sfx, vfx, actor, camera, cg, video, sequence, set, assert, retract, choice, match, hub, jump, end, barrier, quest, on, accept, plugin. A lore artifact adds the entry and beat declaration heads, which the engine looks up rather than plays (Lore entries).

accept (dsl 0.21.0) is the scene-side ::accept{quest}: the engine activates that accept-driven quest if its state is still unset and ignores the record otherwise (Quests & scenes).

end carries an optional free-form reason — an author string ("completed", an ending id) Lute assigns no meaning to and the host MAY surface. Terminating on it is identical to running off the end of commands, except the reason is available.

A line record’s text, a choice/hub option’s text and a branch/hub prompt are always the source language (contentLang), as plain text. When the artifact was built with lute compile --locale-bundle, the record also carries a texts map, locale tag → translated plain text, keyed on the record’s lineId. A choice or hub record with a prompt also carries promptLineId ({prefix}.{branchOrHubId}.prompt, present whenever prompt is, dsl 0.47.0) and, when the bundle translates it, promptTexts (locale tag → translated prompt). A line with inline modifiers carries segments for the source and, per translated locale, localeSegments beside the plain texts entry — every translated locale always has its texts entry, so backlog, TTS, and search never need to flatten segments:

{
"kind": "line",
"family": "content",
"position": "001-0200",
"role": "narration",
"speaker": "narrator",
"text": "Wait for it here it comes.",
"lineId": "opening.narrator_0020",
"voiceKey": "opening.narrator-0020",
"segments": [
{ "text": "Wait for it" },
{ "pause": 0.5 },
{ "text": " " },
{ "text": "here it comes", "rate": 1.25 },
{ "text": "." }
],
"texts": { "ja-JP": "待って 来るよ。" },
"localeSegments": {
"ja-JP": [
{ "text": "待って" },
{ "pause": 0.5 },
{ "text": " " },
{ "text": "来るよ", "rate": 1.25 },
{ "text": "。" }
]
}
}

The locale maps are omitted when empty, so an artifact compiled without --locale-bundle is byte-identical to before, and a consumer that ignores them keeps rendering the source language. Present a locale by looking it up in texts (and localeSegments for a modified line; promptTexts for a prompt) and falling back to text (prompt) — the compiler warns at build time (W-L10N-MISSING) about exactly those gaps, so a complete bundle leaves nothing to fall back to.

The runtime’s line record (lute-runtime / @lute-lang/runtime events over a bundle compiled with --locale-bundle) renders these maps for you (dsl 0.47.0): besides text and segments with their {{…}} markers replaced, it carries texts and localeSegments (when the line command has them) with each translation’s marker replaced by the value the source rendered for the marker with the same referent (the text inside {{ }}, trimmed, so {{ user.voyages }} matches {{user.voyages}}) — a translator may reorder markers, and a marker the source does not have stays as written. The record holds decoded text (lute 0.47.1: no encoding backslashes), and a translation may write \{{, which stays a literal {{ there as in the source. Show a runtime line from its record, not from the artifact’s templates. lute run --json, the conformance record, leaves them out like the other delivery fields. Option labels and prompts are rendered too, since 0.52: the awaitChoice menu carries each option’s text and texts and the menu’s prompt and promptTexts, rendered the same way (see 0.52 additions).

Each surface has its own contract document under docs/runtime/:

  • incremental-continuations.md — checked continuation compilation, full-snapshot replacement, cursor/state retention, stream finalization, and prefix stability.
  • execution-model.md — the artifact shape, version gate, positions, and the dispatcher loop.
  • state-lifecycle.md — the scene/run/user/app/quest.<id> tiers, initialization, and reset boundaries.
  • cel-and-facts.md — evaluating the expr AST, the fact store’s assert/retract deltas, and the stratified least-fixpoint the engine computes.
  • quest-lifecycle.md — start/fail precedence, required vs. optional objectives, monotone completion, run-tier quests, and lifecycle events.
  • beats-and-occasions.md — occasions and their target domains, beat candidates, eligibility and once spending, and the selection order.
  • lore-entries.md — entry eligibility, presentation, first-read effects, and the read / everRead flags.
  • timeline-semantics.md — the local clock, per-track cursors, barriers, and the one-writer-per-target invariant the checker guarantees.
  • bridge-protocol.md — typed bridge calls, return shapes, wait, and resolved state effects.