You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As built (2026-08-07): the osrlib slice — delivery-order item 1 — is complete: spec derivation in #49, phases 10–15 as PRs #50–#61 plus a phase 16 documentation refresh (#62, #63), and the osr-forge gap recorded in mmacy/osr-forge#39. docs/spec.md is now the authority; where the implementation departs from this body, the as-built comment records each delta. Remaining from the delivery order: the osr-web and osr-editor phases.
Important
This is the authoritative, decision-complete design for authored gates, triggers, and quests
across osrlib, osr-web, and osr-editor, filed here because osrlib is the schema's authoritative
home. Every design question it raises, it settles; every load-bearing claim about current
behavior has been verified against the code of all three repos. The next step is deriving the
osrlib spec from this design. Implementation follows the delivery order below.
Problem
An osrlib adventure can be lost but not won. The engine's only terminal state is a battle TPK
(SessionMode.GAME_OVER, assigned solely at battle.py:542); there is no victory, no authored
objective, and no completion detection at any granularity. Content wiring — the lever that opens
the portcullis, the key that fits the lock, the idol the temple pays for — is possible only in
bespoke game code via osrlib's extension surface (event listeners, session flags, referee
commands). Nothing about it is authorable: the adventure document has no field for it, osr-web has
no UI for it, and osr-editor has nothing to edit.
Current state, verified across the three repos
osrlib's Adventure model carries exactly name, description, hooks, town, dungeons, monsters (crawl/adventure.py:47-67). hooks is inert prose — osr-web renders it as town
"rumors" and nothing ever references it mechanically.
There is no item-identity surface for authored objects. The adventure document can define
custom monsters (Adventure.monsters unions bundled MonsterTemplates into the base catalog
via _effective_monsters, crawl/adventure.py:94, with collision reporting) but not custom
items. The equipment catalog is four typed template tuples — WeaponTemplate, ArmourTemplate, GearTemplate, AmmunitionTemplate (core/items.py:320) — each template
carrying an item_type discriminator and an id. Hand-placed cache contents already reference
catalog ids (FeatureSpec.item_ids and magic_item_ids, crawl/dungeon.py:365), GrantItem
validates against the catalog (commands.py:1459), and ItemAcquiredEvent.item_ids carries
catalog ids — so the placement, grant, and observation machinery all exist and all speak
catalog ids; what is missing is a way for an adventure to mint one. Named valuables
(ValuableSpec, crawl/dungeon.py:293-308) carry kind/name/value/weight and no id, so a taken
valuable is invisible to event matching.
The extension surface a quest system rides on all exists and is serialized in saves: listener
protocol and state store, session.flags, SetFlag/AwardXP/GrantCoins/GrantItem, and
trigger-suitable events (LocationEnteredEvent, ItemAcquiredEvent, MonsterDefeatedEvent). FlagSetEvent is referee-visibility by design — content wiring is the game's secret.
The replay contract constrains where an interpreter can live. Replays rebuild state by
re-executing the command log with no listeners registered; listener-issued commands are already
in the log (persistence.py:281-331; osrlib docs/spec.md: "replays reproduce engine state
exactly with or without listeners"). An always-on interpreter inside the session would fire
again during replay and double-issue its consequences.
Doors are authorable as locked (DoorSpec, dungeon.py:183-196) but the only player-facing
unlock is the thief's PickLock (ForceDoor explicitly defers: "locked doors need PickLock,
not muscle"). There is no key-item concept. The referee command SetDoorState
(commands.py:1631) can rewrite any door's overlay anywhere — the lever-opens-portcullis
primitive — but nothing authored drives it.
osrlib's spec (docs/spec.md:171) excludes quest systems from the library: "game-design
systems with no SRD basis — quests, achievements, story progression, dialogue — belong to the
game." The same spec's session-flags bullet names "a lever in room 3 opens the portcullis in
room 12" as something dungeon content reads flags for — so the gates layer is close to
blessed already, and the quest layer arrives as a first-party implementation of the documented
extension surface rather than a new in-session system.
The one worked example in the ecosystem is examples/tui_crawler/quest.py — an 80-line fetch
quest listener, deliberately outside the library, written as proof that the extension surface
is sufficient for game authors. It matches its idol by display name because there is nothing
else to match on. It is not a surface adventure authors can reach.
osr-web consumes only the six Adventure fields; PlayerView (crawl/views.py:134-156) is a
closed whitelist with no field for flags, progress, or objectives — session.py:802-804
asserts the exclusion — so quest state has no route to a client by construction, and no
consumer can evaluate conditions itself. Its transcript log is ephemeral (wiped on every game
entry); it has a deterministic narrator (server/narrate.py) plus an LLM overlay
(server/narration.py) whose per-event tone hints are hardcoded. It explicitly swallows FlagSetEvent.
osr-editor has zero quest code, but its spec anticipated this feature: quests are a named
future extension (docs/spec.md:267) with the growth path pre-written — osrlib owns the
schema, generated types pick it up, ops grow additively, triggers place on the map the way
transitions do, diagnostics lint dangling quest references the way they lint monster ids.
Design overview
Three layers. Mechanics are two primitives — gates and triggers — with quests composed from both.
Narrative is authored text attached to mechanical objects. Presentation is who renders that text.
The two primitives live at different depths of the engine, and that layering is itself a design
commitment:
Gates are in-engine command-legality checks, evaluated exactly the way locked is today.
They mutate nothing, so replay is unaffected.
The trigger/quest interpreter is a library-shipped Listener that games register — the
existing extension contract. Everything the interpreter does lands in the command log as
ordinary commands, and all quest, trigger, and journal state is engine-owned session state
mutated only by those commands — so a replay, which runs with no listeners, rebuilds quest
state exactly by re-executing the log. The interpreter holds no state of its own that the
engine cannot reconstruct.
This layering also resolves the library-posture tension: gates are dungeon furniture osrlib's
spec already gestures at, and the quest layer arrives as a first-party implementation of the
documented extension surface.
Item identity
The adventure document gains bundled item definitions, mirroring the Adventure.monsters
precedent exactly:
Adventure.items is a tuple of item templates — the existing WeaponTemplate | ArmourTemplate | GearTemplate | AmmunitionTemplate union, discriminated on the item_type
field each template already carries. A quest key or idol is authored as a GearTemplate; the
surface does not artificially exclude a named weapon or armour piece.
An _effective_items union (mirroring _effective_monsters) merges bundled templates into the
base equipment catalog at session start and inside validate_adventure. Shipped ids win;
collisions are reported exactly as monster template collisions are.
Everything downstream is existing machinery, unchanged: bundled items place in caches through FeatureSpec.item_ids, GrantItem grants them because it validates against the effective
catalog, ItemAcquiredEvent carries their ids because it already carries catalog ids, and validate_adventure resolves every item reference against the effective catalog.
ValuableSpec stays id-less. Valuables remain generic, sellable treasure; a MacGuffin is an
authored item placed in a cache, not a valuable. This keeps exactly one item-identity namespace —
the catalog — and eliminates the display-name matching the worked example was forced into. If a
"this specific gem is also a quest target" need ever materializes, an optional id on ValuableSpec can arrive additively; nothing here forecloses it.
Conditions and gates
A condition is a deterministic predicate over session state, evaluated live at the moment the
gated thing is attempted (level-triggered, not edge-triggered). Live evaluation is the point: a
flag set at item pickup drifts from the truth when the party drops or sells the item; a predicate
checked at use time cannot. The vocabulary:
has_item(item_id, consumes=False) — some party member's carried inventory contains an item
whose catalog id (equipment or magic-item) matches. Any member's carried inventory is the whole
test: items live in member inventories (GrantItem takes a character_id), there is no shared
pile, and equipped-only would break the toll-coin case. With consumes=True, one instance is
removed when the gated command succeeds — consumption is an effect of the successful command,
reported through its events, never a side effect of condition evaluation, which stays pure
(this is what keeps the gates-mutate-nothing replay argument true). The instance is taken from
the first holder in party order.
flag_equals(key, value) — a session flag holds a value.
effect_active(kind) — an active effect whose EffectDefinition.kind matches is attached to
a party member (the light precedent: authors choose between "merely carrying the talisman
suffices" and "you must invoke it").
Conditions form a discriminated union that grows additively — this union is the primary seam for
future condition kinds (NPC-related conditions among them).
A gate is an optional requires condition on an interaction spec. Version one scopes gates to DoorSpec.requires and TransitionSpec.requires — specs that content packs do not carry (see
the packs seam). Two further gate sites are part of the design but trail the first slice, riding
the pack-interaction decision when it comes: TrapSpec.bypass (the trap does not spring for a
party meeting the condition) and condition-keyed prose variants on AreaSpec/FeatureSpec
descriptions (the feather-holder notices the draft, the scroll-bearer reads the inscription).
When prose variants land they are engine-resolved — see the visibility section.
Gates and the lock machinery
requires and locked are orthogonal layers, not one mechanism. Unifying them — re-expressing locked as a built-in gate — was considered and rejected: locked is SRD-grounded, stateful,
edge-triggered mechanics (thief skill rolls, per-character lockouts, overlay mutation on
success), while a gate is a stateless authored predicate. Merging them would force dice,
character state, and lockout tracking into the condition union — a merge of unlikes that reads
as simplification and is not.
The composition rules:
When a door carries both, the lock and the condition must each be satisfied to operate it.
PickLock addresses only the lock, never the gate. A thief is not a universal quest bypass;
an author who wants a pickable obstacle authors locked without a gate.
SetDoorState rewrites the state overlay only and never touches requires — specs are
immutable ("play mutates the overlay, never this spec").
A door standing open admits passage without a gate check. Triggers open gated doors by setting
them open, not by clearing the gate — the portcullis raised by the lever is open regardless of
what raising it by hand would require. If the door closes again, the gate re-applies; live
evaluation stays meaningful.
A gate refusal is a command rejection with its own codes — exploration.door.gate_refused and exploration.transition.gate_refused — distinct from exploration.door.locked, with the
gate's authored refusal text carried alongside the code so the renderer can show it. Success
text rides the normal door and transition events as an optional authored beat.
Triggers
A trigger is an authored binding: when an observable event matches a pattern (optionally further
gated by conditions), issue referee commands. Edge-triggered. The observables are the existing
events (LocationEnteredEvent, ItemAcquiredEvent, MonsterDefeatedEvent, FlagSetEvent);
the consequence surface authors reach is the existing referee command vocabulary (AwardXP, GrantCoins, GrantItem, SetFlag, SetDoorState, SpawnMonsters, SpawnNpcParty). The
semantics:
Once-only by default, with repeatable: true as the authored opt-in. Once is the common
authored case (the ambush, the reveal, the reward) and caps cascade blast radius. Fired-state
is engine session state (see the interpreter section), so once-only survives save/load and
rebuilds under replay.
Deterministic ordering. Multiple triggers matching one event fire in document order —
stable and author-controllable. Consequences within a trigger execute in authored order. All
consequences of a batch execute before any subsequent player command.
Bounded cascades.SetFlag is a consequence and FlagSetEvent is an observable, so
trigger chains are constructible — and flag-chains are legitimate wiring, so the answer is a
bound, not suppression. Events produced by a player command are depth 0; events produced by a
trigger's consequences are one deeper than the events that fired it. Triggers match events of
depth 4 or less; deeper events are not offered to the interpreter, and truncation records a
referee-visibility note. The editor carries an advisory cycle lint (see consumers); runtime
boundedness is the guarantee, so a cycle is suspicious authoring, not invalid content.
Rejected consequences are dropped and recorded. Referee spawns reject when an encounter is
already open — and MoveParty can emit LocationEnteredEvent and open a wandering encounter
in the same command result, so "ambush at the shrine" can collide with a random encounter
immediately. A dropped consequence records a referee-visibility note; the editor lints
collision-prone pairings. No retry or queue — deferred consequences firing later would be
undebuggable.
Rewards and treasure XP. Trigger-granted treasure follows the same valuation rules as any
treasure at the moment it lands: granted mid-dungeon it counts in the next return delta;
granted at town it lands outside any delta and earns no treasure XP. No valuation bookkeeping
is added or changed. Authored XP belongs in AwardXP — treasure XP is for recovered treasure —
and the editor carries an advisory lint for a coin or item reward with no accompanying AwardXP.
Timing honesty.MonsterDefeatedEvent emits at battle end, so "the portcullis opens the
moment the boss falls, mid-fight" is not authorable in version one. Stated so authors'
expectations are set.
The interpreter and the command log
The interpreter observes events, decides, and acts exclusively by issuing commands — because
replays run with no listeners, anything the interpreter merely remembered would be lost to a
replay. Quest, trigger, and journal state therefore live in the engine session, mutated only by
a small family of lifecycle referee commands the interpreter issues (authors author QuestSpecs
and triggers, never these commands):
MarkTriggerFired(trigger_id) — records the fired mark in session trigger state and emits a
referee-visibility TriggerFiredEvent. Issued before the trigger's consequences.
ActivateQuest(quest_id), RevealObjective(quest_id, objective_id), CompleteObjective(quest_id, objective_id), CompleteQuest(quest_id) — advance session quest
state; their execution reads the QuestSpec from the adventure document, emits the
player-visible events carrying the authored narrative, and appends the journal entries. CompleteQuest on a quest marked as concluding the adventure transitions the session to
victory (below).
AddJournalEntry(...) — appends a journal entry outside a quest lifecycle beat; how one-off
triggers get a journal voice.
RecordNote(...) — no state effect; emits a referee-visibility event. The mechanism behind
the dropped-consequence and cascade-truncation records, and generally useful to game-side
listeners.
These are ordinary logged, replayed commands; re-executing them rebuilds quest state, journal,
and fired-marks exactly, with no interpreter present. In addition, the base Command gains an
optional source: str | None = None field — ignored by execution, additive within the schema
version, replay-safe — which the interpreter stamps with the owning trigger or quest id on every
consequence it issues. Without it, "why did the party get 500 XP" is unanswerable from the log.
Quest reward commands issue immediately after CompleteQuest, in authored order, before any
subsequent player command.
Quests
A QuestSpec composes the primitives:
id, name, and a narrative block
Activation: a quest is dormant until its activation trigger fires; a quest with no
activation trigger is active from session start. There is no accept/decline interaction — a
B/X module frames the objective, it does not negotiate. The offer beat displays at activation.
Objectives: each an authored trigger plus narrative, optionally hidden. A hidden objective
reveals when its optional reveal trigger fires, or on completion if it has none.
Rewards: referee commands issued on completion.
Completion rule: all objectives, or any.
An optional marker designating that completing this quest concludes the adventure.
The journal
The journal is an appended, event-sourced list in session state — never derived on demand.
Entries append when beats land: quest activation, objective reveal and completion, quest
completion, and any one-off trigger whose narrative block carries a journal form. Appending
preserves order-of-discovery, keeps beats whose source state has since changed (the third firing
of a repeatable trigger, a revealed-then-completed objective), gives triggers a journal voice
without a quest attached — and derivation is foreclosed anyway, since consumers cannot evaluate
quest state by construction. The journal persists in saves and ships to clients verbatim through PlayerView.
Narrative blocks
Every mechanical object (quest, objective, gate, trigger) carries an optional narrative block
with three distinct audiences:
Display beats — text the deterministic renderer shows verbatim: offer, progress, and
completion text for quests; refusal and success text for gates ("the door doesn't budge; a
shallow slot suggests something might fit"); fired text for triggers.
Journal form — the entry recorded when the beat lands, phrased for a persistent journal
rather than a transcript.
LLM guidance — authored steering that is never displayed verbatim. The guidance field
lives on the narrative block itself, so every object that carries narrative carries steering
uniformly, plus one ambient guidance slot per dungeon level ("this level should feel
increasingly wrong the deeper they go" attaches to no mechanical object). No other scoping
machinery: guidance applies while its carrier is in play — quest active, gate encountered,
trigger fired, level occupied. Trust posture: guidance is trusted as content, the same posture
as description prose, which already flows into narration prompts today.
An optional speaker attribution stays free prose (see the NPC seam).
Visibility
Player-visible events carry authored text; the wiring that produced them — flags, conditions,
trigger internals — stays referee-only. The new player-visible event kinds are the quest
lifecycle events (QuestActivatedEvent, ObjectiveRevealedEvent, ObjectiveCompletedEvent, QuestCompletedEvent), JournalEntryAddedEvent, and AdventureCompletedEvent; the new
referee-visibility kinds are TriggerFiredEvent and the RecordNote event. Gate refusal text
rides the command rejection; gate success text rides the existing door and transition events.
Condition-keyed prose variants, when they land, are engine-resolved at emission time and
shipped as text. Consumers cannot evaluate conditions by construction (PlayerView is a
closed whitelist and the flags exclusion is asserted in code), and growing a consumer-facing
condition surface would breach the backend-is-authoritative division all three repos are built
on. Engine-side resolution also keeps the secret wiring secret: the client sees the resolved
prose, never the condition.
PlayerView gains active quests (id, name, display narrative, visible objectives and their
states) and the journal.
Victory and session end
Adventure completion gets both a terminal event and a terminal mode: CompleteQuest on the
concluding quest emits a player-visible AdventureCompletedEvent carrying the authored
completion narrative and transitions the session to SessionMode.VICTORY (wire value "victory"), a terminal mode mirroring GAME_OVER — play commands are illegal, referee
commands remain legal (which is also what lets rewards land after the transition).
The session concludes rather than continuing. The adventure module is the playable unit
throughout the ecosystem; campaign continuity is a non-goal; and post-victory play drags in a
swamp of underspecified questions (what is legal after winning, does XP accrue, can you die
after victory). Concluding now and adding a post-victory mode later is additive; shipping
"continue" first and tightening later is breaking.
This work also fixes an existing gap in the terminal-state machinery rather than inheriting it:
a non-battle party wipe (trap, deprivation) currently leaves the session in EXPLORING forever.
Non-battle wipes route to GAME_OVER. The delicate machinery is opened once, for both changes.
Engine changes implied
Adventure.items and the _effective_items union, at session start and in validation
Gate evaluation in the command handlers — a legality check beside locked, plus the
gate-refusal rejection codes and the consume-on-success effect
The trigger/quest interpreter as a library-shipped Listener; the quest lifecycle referee
commands; the optional source field on the base Command
Session state for quest, trigger, and journal progress, persisted in saves
PlayerView additions: active quests, journal
The new player-visible and referee-visibility event kinds
SessionMode.VICTORY, AdventureCompletedEvent, and the non-battle-TPK routing to GAME_OVER
validate_adventure grows hard reference checks — item ids in conditions, caches, and
consequences against the effective catalog; monster, area, and dungeon/level references in
triggers and transitions; quest and objective ids in lifecycle references
Consumers
osr-web: registers the library interpreter; the renderer passes authored text through; a
persistent journal panel (distinct from the ephemeral log); a victory screen on SessionMode.VICTORY; the LLM narration prompt gains active quest state, recent journal
entries, and authored steering — replacing or augmenting today's hardcoded tone hints.
osr-editor: regenerated types pick up the schema; the ops vocabulary grows additively; a
quests panel joins the nav (an eighth target); gates and triggers place on the map the way
transitions do. Lint grows: dangling-reference diagnostics mirroring validate_adventure;
advisory lints for flag reads with no writer, trigger cycles, collision-prone
trigger/encounter pairings, and treasure rewards with no accompanying AwardXP; and a
reachability class ("door requires a key that is not placed anywhere") scoped to the decidable
core — an id appearing in no cache, no trigger consequence, and no purchasable catalog entry —
and advisory forever, because trigger-granted items and random treasure make the general
problem undecidable, and lint warns while only validate_adventure gates publish.
Delivery order
osrlib — the schema and engine work, spec first. Everything downstream hangs off it. The
slice that ships the interpreter also converts the worked example: examples/tui_crawler
registers the library interpreter and authors its fetch quest as data in its adventure
document, and the hand-rolled quest.py listener is deleted. Its job was proving the
extension surface sufficed; once the library ships the interpreter, its job is demonstrating
the authored surface, and keeping the hand-rolled version would leave two quest patterns in
the repo. Closing deliverable of this phase: file the osr-forge issue recording the
overrides-vocabulary gap (see the forge seam).
osr-web — prove the model plays before freezing authoring on it: interpreter
registration, journal, victory, LLM context.
osr-editor — the authoring surface, as its spec already prescribes.
The first slice is gates on doors and transitions. It is the right first slice not because it is
small and independent but because its tiny surface area forces the two hardest schema questions —
item identity and lock composition — while everything else is still cheap to change, and it
pressure-tests the condition vocabulary before quests build on it.
Seams and scope guards
NPCs: seam only, not folded in
osrlib's spec puts dialogue in the same belongs-to-the-game exclusion as quests, and an NPC model
without dialogue is just prose — which narrative blocks already carry. The engine already has
NPC-shaped verbs (SpawnNpcParty — whose docstring names quest listeners as an intended caller —
and Parley); what it lacks is persistent named NPC entities, which nothing here requires. The
seam commitments:
Conditions and triggers are discriminated unions; spoke_to(npc_id) or npc_present arrive
later as additive variants.
Narrative speaker stays free prose now; a typed npc_id arrives alongside it additively
when NPCs land. Prose is never retrofitted into a reference.
NPCs get their own id namespace when they arrive; the dangling-reference lint pattern extends.
The consequence surface is "referee commands," full stop — already open.
No closed enumerations anywhere an NPC would eventually slot (no literal "town" as the only
quest source, no "monsters" as the only encounterable thing).
Adventure hooks
hooks stays exactly as it is: inert, diegetic rumor prose. With quests carrying offer
narrative, the document has two "why the party goes" surfaces, and the overlap was examined
deliberately: they are different registers — rumors are town color a game renders
atmospherically, offers are the mechanical entry beat of a specific quest — and neither subsumes
the other. No unification, and no mechanical linkage from a rumor to a quest id; if a typed
linkage ever wants to exist it arrives additively, under the same rule as speaker (prose is
never retrofitted into a reference).
Forge-backed projects
Quest authoring is native-project-only at first. In a forge-backed project the editor writes overrides.yaml — osr-forge's schema — and that schema has no quest surface; growing one is
osr-forge's decision, made in its own repo against a settled osrlib schema. The editor disables
quest surfaces in forge-backed projects with an affordance explaining why. No shim is built:
this is sequencing, not accommodation, and the osr-forge issue recording the vocabulary gap is a
closing deliverable of the osrlib phase, not a someday.
Content packs
Quests are adventure-scoped and stay out of packs. The deferral has a sharp edge the v1 scoping
exists to blunt: ContentPackEntry embeds FeatureSpec, trap, and treasure directly
(crawl/content_pack.py:104-107), so a gate on those specs would ride into arbitrary target
adventures — carrying dangling item and flag references — the same release it shipped. Hence
gates v1 lands on DoorSpec/TransitionSpec only (which packs do not carry), and TrapSpec.bypass plus prose variants ride the pack-interaction decision when it comes. Pack
features referencing item ids reference the shipped catalog only — adventure-bundled item ids
are adventure-scoped by construction, and pack insertion validates references against the target
adventure's effective catalog.
Serialization and versioning
Everything here is additive and nothing bumps SCHEMA_VERSION:
New optional fields (requires, Adventure.items, Command.source, narrative blocks) and
new event types are within-version by written policy (versioning.py:30-38).
The new save-state blocks (quest, trigger, journal state) are new optional fields — same rule.
Adding the SessionMode.VICTORY value is additive-within-version, and this work codifies that
rule into the versioning policy: a new enum value has the same risk profile as a new event
type, which the policy already blesses — old artifacts never contain it, and an older reader
rejecting a newer artifact is a possibility the new-event-type rule already accepts. (The
existing docstring says changing wire values is a bump; adding one is now addressed in
writing, so the next addition does not reopen the question.)
The design of the shared single SCHEMA_VERSION integer itself — one number across saves,
characters, parties, packs, and stamped documents — is out of scope here; it deserves its own
issue if a bump-forcing change ever approaches. The editor's canonical-serialization
byte-stability invariant must hold for documents that use none of the new fields, and its
open-fields fidelity guard already protects older editors from newer documents in the interim.
Commitments at a glance
Item identity: Adventure.items bundles existing item templates into the effective catalog,
mirroring monsters; ValuableSpec stays id-less.
Gates: in-engine legality checks on DoorSpec/TransitionSpec; conditions are has_item
(any member's carried inventory, optional consume-on-success), flag_equals, effect_active(kind).
Locks: orthogonal to gates; both must pass; PickLock never bypasses a gate; SetDoorState
never touches requires; an open door admits passage; distinct gate-refusal rejection codes.
Triggers: once-only default, document-order firing, cascade depth bounded at 4, rejected
consequences dropped and recorded, treasure rewards follow normal valuation with authored XP
via AwardXP.
Interpreter: library-shipped Listener; all state engine-owned and command-mutated (lifecycle
referee commands); optional source provenance on every command.
Quests: activation-trigger model, no accept/decline; hidden objectives reveal by trigger or on
completion; rewards issue immediately on completion.
Journal: appended event-sourced session state, shipped verbatim in PlayerView.
LLM guidance: on the narrative block uniformly plus a per-dungeon-level ambient slot; trusted
as content.
Note
As built (2026-08-07): the osrlib slice — delivery-order item 1 — is complete: spec derivation in #49, phases 10–15 as PRs #50–#61 plus a phase 16 documentation refresh (#62, #63), and the osr-forge gap recorded in mmacy/osr-forge#39.
docs/spec.mdis now the authority; where the implementation departs from this body, the as-built comment records each delta. Remaining from the delivery order: the osr-web and osr-editor phases.Important
This is the authoritative, decision-complete design for authored gates, triggers, and quests
across osrlib, osr-web, and osr-editor, filed here because osrlib is the schema's authoritative
home. Every design question it raises, it settles; every load-bearing claim about current
behavior has been verified against the code of all three repos. The next step is deriving the
osrlib spec from this design. Implementation follows the delivery order below.
Problem
An osrlib adventure can be lost but not won. The engine's only terminal state is a battle TPK
(
SessionMode.GAME_OVER, assigned solely atbattle.py:542); there is no victory, no authoredobjective, and no completion detection at any granularity. Content wiring — the lever that opens
the portcullis, the key that fits the lock, the idol the temple pays for — is possible only in
bespoke game code via osrlib's extension surface (event listeners, session flags, referee
commands). Nothing about it is authorable: the adventure document has no field for it, osr-web has
no UI for it, and osr-editor has nothing to edit.
Current state, verified across the three repos
Adventuremodel carries exactlyname,description,hooks,town,dungeons,monsters(crawl/adventure.py:47-67).hooksis inert prose — osr-web renders it as town"rumors" and nothing ever references it mechanically.
custom monsters (
Adventure.monstersunions bundledMonsterTemplates into the base catalogvia
_effective_monsters,crawl/adventure.py:94, with collision reporting) but not customitems. The equipment catalog is four typed template tuples —
WeaponTemplate,ArmourTemplate,GearTemplate,AmmunitionTemplate(core/items.py:320) — each templatecarrying an
item_typediscriminator and anid. Hand-placed cache contents already referencecatalog ids (
FeatureSpec.item_idsandmagic_item_ids,crawl/dungeon.py:365),GrantItemvalidates against the catalog (
commands.py:1459), andItemAcquiredEvent.item_idscarriescatalog ids — so the placement, grant, and observation machinery all exist and all speak
catalog ids; what is missing is a way for an adventure to mint one. Named valuables
(
ValuableSpec,crawl/dungeon.py:293-308) carry kind/name/value/weight and no id, so a takenvaluable is invisible to event matching.
protocol and state store,
session.flags,SetFlag/AwardXP/GrantCoins/GrantItem, andtrigger-suitable events (
LocationEnteredEvent,ItemAcquiredEvent,MonsterDefeatedEvent).FlagSetEventis referee-visibility by design — content wiring is the game's secret.re-executing the command log with no listeners registered; listener-issued commands are already
in the log (
persistence.py:281-331; osrlibdocs/spec.md: "replays reproduce engine stateexactly with or without listeners"). An always-on interpreter inside the session would fire
again during replay and double-issue its consequences.
locked(DoorSpec,dungeon.py:183-196) but the only player-facingunlock is the thief's
PickLock(ForceDoorexplicitly defers: "locked doors need PickLock,not muscle"). There is no key-item concept. The referee command
SetDoorState(
commands.py:1631) can rewrite any door's overlay anywhere — the lever-opens-portcullisprimitive — but nothing authored drives it.
docs/spec.md:171) excludes quest systems from the library: "game-designsystems with no SRD basis — quests, achievements, story progression, dialogue — belong to the
game." The same spec's session-flags bullet names "a lever in room 3 opens the portcullis in
room 12" as something dungeon content reads flags for — so the gates layer is close to
blessed already, and the quest layer arrives as a first-party implementation of the documented
extension surface rather than a new in-session system.
examples/tui_crawler/quest.py— an 80-line fetchquest listener, deliberately outside the library, written as proof that the extension surface
is sufficient for game authors. It matches its idol by display name because there is nothing
else to match on. It is not a surface adventure authors can reach.
PlayerView(crawl/views.py:134-156) is aclosed whitelist with no field for flags, progress, or objectives —
session.py:802-804asserts the exclusion — so quest state has no route to a client by construction, and no
consumer can evaluate conditions itself. Its transcript log is ephemeral (wiped on every game
entry); it has a deterministic narrator (
server/narrate.py) plus an LLM overlay(
server/narration.py) whose per-event tone hints are hardcoded. It explicitly swallowsFlagSetEvent.future extension (
docs/spec.md:267) with the growth path pre-written — osrlib owns theschema, generated types pick it up, ops grow additively, triggers place on the map the way
transitions do, diagnostics lint dangling quest references the way they lint monster ids.
Design overview
Three layers. Mechanics are two primitives — gates and triggers — with quests composed from both.
Narrative is authored text attached to mechanical objects. Presentation is who renders that text.
The two primitives live at different depths of the engine, and that layering is itself a design
commitment:
lockedis today.They mutate nothing, so replay is unaffected.
Listenerthat games register — theexisting extension contract. Everything the interpreter does lands in the command log as
ordinary commands, and all quest, trigger, and journal state is engine-owned session state
mutated only by those commands — so a replay, which runs with no listeners, rebuilds quest
state exactly by re-executing the log. The interpreter holds no state of its own that the
engine cannot reconstruct.
This layering also resolves the library-posture tension: gates are dungeon furniture osrlib's
spec already gestures at, and the quest layer arrives as a first-party implementation of the
documented extension surface.
Item identity
The adventure document gains bundled item definitions, mirroring the
Adventure.monstersprecedent exactly:
Adventure.itemsis a tuple of item templates — the existingWeaponTemplate | ArmourTemplate | GearTemplate | AmmunitionTemplateunion, discriminated on theitem_typefield each template already carries. A quest key or idol is authored as a
GearTemplate; thesurface does not artificially exclude a named weapon or armour piece.
_effective_itemsunion (mirroring_effective_monsters) merges bundled templates into thebase equipment catalog at session start and inside
validate_adventure. Shipped ids win;collisions are reported exactly as monster template collisions are.
FeatureSpec.item_ids,GrantItemgrants them because it validates against the effectivecatalog,
ItemAcquiredEventcarries their ids because it already carries catalog ids, andvalidate_adventureresolves every item reference against the effective catalog.ValuableSpecstays id-less. Valuables remain generic, sellable treasure; a MacGuffin is anauthored item placed in a cache, not a valuable. This keeps exactly one item-identity namespace —
the catalog — and eliminates the display-name matching the worked example was forced into. If a
"this specific gem is also a quest target" need ever materializes, an optional id on
ValuableSpeccan arrive additively; nothing here forecloses it.Conditions and gates
A condition is a deterministic predicate over session state, evaluated live at the moment the
gated thing is attempted (level-triggered, not edge-triggered). Live evaluation is the point: a
flag set at item pickup drifts from the truth when the party drops or sells the item; a predicate
checked at use time cannot. The vocabulary:
has_item(item_id, consumes=False)— some party member's carried inventory contains an itemwhose catalog id (equipment or magic-item) matches. Any member's carried inventory is the whole
test: items live in member inventories (
GrantItemtakes acharacter_id), there is no sharedpile, and equipped-only would break the toll-coin case. With
consumes=True, one instance isremoved when the gated command succeeds — consumption is an effect of the successful command,
reported through its events, never a side effect of condition evaluation, which stays pure
(this is what keeps the gates-mutate-nothing replay argument true). The instance is taken from
the first holder in party order.
flag_equals(key, value)— a session flag holds a value.effect_active(kind)— an active effect whoseEffectDefinition.kindmatches is attached toa party member (the light precedent: authors choose between "merely carrying the talisman
suffices" and "you must invoke it").
Conditions form a discriminated union that grows additively — this union is the primary seam for
future condition kinds (NPC-related conditions among them).
A gate is an optional
requirescondition on an interaction spec. Version one scopes gates toDoorSpec.requiresandTransitionSpec.requires— specs that content packs do not carry (seethe packs seam). Two further gate sites are part of the design but trail the first slice, riding
the pack-interaction decision when it comes:
TrapSpec.bypass(the trap does not spring for aparty meeting the condition) and condition-keyed prose variants on
AreaSpec/FeatureSpecdescriptions (the feather-holder notices the draft, the scroll-bearer reads the inscription).
When prose variants land they are engine-resolved — see the visibility section.
Gates and the lock machinery
requiresandlockedare orthogonal layers, not one mechanism. Unifying them — re-expressinglockedas a built-in gate — was considered and rejected:lockedis SRD-grounded, stateful,edge-triggered mechanics (thief skill rolls, per-character lockouts, overlay mutation on
success), while a gate is a stateless authored predicate. Merging them would force dice,
character state, and lockout tracking into the condition union — a merge of unlikes that reads
as simplification and is not.
The composition rules:
PickLockaddresses only the lock, never the gate. A thief is not a universal quest bypass;an author who wants a pickable obstacle authors
lockedwithout a gate.SetDoorStaterewrites the state overlay only and never touchesrequires— specs areimmutable ("play mutates the overlay, never this spec").
them open, not by clearing the gate — the portcullis raised by the lever is open regardless of
what raising it by hand would require. If the door closes again, the gate re-applies; live
evaluation stays meaningful.
exploration.door.gate_refusedandexploration.transition.gate_refused— distinct fromexploration.door.locked, with thegate's authored refusal text carried alongside the code so the renderer can show it. Success
text rides the normal door and transition events as an optional authored beat.
Triggers
A trigger is an authored binding: when an observable event matches a pattern (optionally further
gated by conditions), issue referee commands. Edge-triggered. The observables are the existing
events (
LocationEnteredEvent,ItemAcquiredEvent,MonsterDefeatedEvent,FlagSetEvent);the consequence surface authors reach is the existing referee command vocabulary (
AwardXP,GrantCoins,GrantItem,SetFlag,SetDoorState,SpawnMonsters,SpawnNpcParty). Thesemantics:
repeatable: trueas the authored opt-in. Once is the commonauthored case (the ambush, the reveal, the reward) and caps cascade blast radius. Fired-state
is engine session state (see the interpreter section), so once-only survives save/load and
rebuilds under replay.
stable and author-controllable. Consequences within a trigger execute in authored order. All
consequences of a batch execute before any subsequent player command.
SetFlagis a consequence andFlagSetEventis an observable, sotrigger chains are constructible — and flag-chains are legitimate wiring, so the answer is a
bound, not suppression. Events produced by a player command are depth 0; events produced by a
trigger's consequences are one deeper than the events that fired it. Triggers match events of
depth 4 or less; deeper events are not offered to the interpreter, and truncation records a
referee-visibility note. The editor carries an advisory cycle lint (see consumers); runtime
boundedness is the guarantee, so a cycle is suspicious authoring, not invalid content.
already open — and
MovePartycan emitLocationEnteredEventand open a wandering encounterin the same command result, so "ambush at the shrine" can collide with a random encounter
immediately. A dropped consequence records a referee-visibility note; the editor lints
collision-prone pairings. No retry or queue — deferred consequences firing later would be
undebuggable.
treasure at the moment it lands: granted mid-dungeon it counts in the next return delta;
granted at town it lands outside any delta and earns no treasure XP. No valuation bookkeeping
is added or changed. Authored XP belongs in
AwardXP— treasure XP is for recovered treasure —and the editor carries an advisory lint for a coin or item reward with no accompanying
AwardXP.MonsterDefeatedEventemits at battle end, so "the portcullis opens themoment the boss falls, mid-fight" is not authorable in version one. Stated so authors'
expectations are set.
The interpreter and the command log
The interpreter observes events, decides, and acts exclusively by issuing commands — because
replays run with no listeners, anything the interpreter merely remembered would be lost to a
replay. Quest, trigger, and journal state therefore live in the engine session, mutated only by
a small family of lifecycle referee commands the interpreter issues (authors author
QuestSpecsand triggers, never these commands):
MarkTriggerFired(trigger_id)— records the fired mark in session trigger state and emits areferee-visibility
TriggerFiredEvent. Issued before the trigger's consequences.ActivateQuest(quest_id),RevealObjective(quest_id, objective_id),CompleteObjective(quest_id, objective_id),CompleteQuest(quest_id)— advance session queststate; their execution reads the
QuestSpecfrom the adventure document, emits theplayer-visible events carrying the authored narrative, and appends the journal entries.
CompleteQueston a quest marked as concluding the adventure transitions the session tovictory (below).
AddJournalEntry(...)— appends a journal entry outside a quest lifecycle beat; how one-offtriggers get a journal voice.
RecordNote(...)— no state effect; emits a referee-visibility event. The mechanism behindthe dropped-consequence and cascade-truncation records, and generally useful to game-side
listeners.
These are ordinary logged, replayed commands; re-executing them rebuilds quest state, journal,
and fired-marks exactly, with no interpreter present. In addition, the base
Commandgains anoptional
source: str | None = Nonefield — ignored by execution, additive within the schemaversion, replay-safe — which the interpreter stamps with the owning trigger or quest id on every
consequence it issues. Without it, "why did the party get 500 XP" is unanswerable from the log.
Quest reward commands issue immediately after
CompleteQuest, in authored order, before anysubsequent player command.
Quests
A
QuestSpeccomposes the primitives:id,name, and a narrative blockactivation trigger is active from session start. There is no accept/decline interaction — a
B/X module frames the objective, it does not negotiate. The offer beat displays at activation.
reveals when its optional reveal trigger fires, or on completion if it has none.
The journal
The journal is an appended, event-sourced list in session state — never derived on demand.
Entries append when beats land: quest activation, objective reveal and completion, quest
completion, and any one-off trigger whose narrative block carries a journal form. Appending
preserves order-of-discovery, keeps beats whose source state has since changed (the third firing
of a repeatable trigger, a revealed-then-completed objective), gives triggers a journal voice
without a quest attached — and derivation is foreclosed anyway, since consumers cannot evaluate
quest state by construction. The journal persists in saves and ships to clients verbatim through
PlayerView.Narrative blocks
Every mechanical object (quest, objective, gate, trigger) carries an optional narrative block
with three distinct audiences:
completion text for quests; refusal and success text for gates ("the door doesn't budge; a
shallow slot suggests something might fit"); fired text for triggers.
rather than a transcript.
lives on the narrative block itself, so every object that carries narrative carries steering
uniformly, plus one ambient guidance slot per dungeon level ("this level should feel
increasingly wrong the deeper they go" attaches to no mechanical object). No other scoping
machinery: guidance applies while its carrier is in play — quest active, gate encountered,
trigger fired, level occupied. Trust posture: guidance is trusted as content, the same posture
as description prose, which already flows into narration prompts today.
An optional
speakerattribution stays free prose (see the NPC seam).Visibility
Player-visible events carry authored text; the wiring that produced them — flags, conditions,
trigger internals — stays referee-only. The new player-visible event kinds are the quest
lifecycle events (
QuestActivatedEvent,ObjectiveRevealedEvent,ObjectiveCompletedEvent,QuestCompletedEvent),JournalEntryAddedEvent, andAdventureCompletedEvent; the newreferee-visibility kinds are
TriggerFiredEventand theRecordNoteevent. Gate refusal textrides the command rejection; gate success text rides the existing door and transition events.
Condition-keyed prose variants, when they land, are engine-resolved at emission time and
shipped as text. Consumers cannot evaluate conditions by construction (
PlayerViewis aclosed whitelist and the flags exclusion is asserted in code), and growing a consumer-facing
condition surface would breach the backend-is-authoritative division all three repos are built
on. Engine-side resolution also keeps the secret wiring secret: the client sees the resolved
prose, never the condition.
PlayerViewgains active quests (id, name, display narrative, visible objectives and theirstates) and the journal.
Victory and session end
Adventure completion gets both a terminal event and a terminal mode:
CompleteQueston theconcluding quest emits a player-visible
AdventureCompletedEventcarrying the authoredcompletion narrative and transitions the session to
SessionMode.VICTORY(wire value"victory"), a terminal mode mirroringGAME_OVER— play commands are illegal, refereecommands remain legal (which is also what lets rewards land after the transition).
The session concludes rather than continuing. The adventure module is the playable unit
throughout the ecosystem; campaign continuity is a non-goal; and post-victory play drags in a
swamp of underspecified questions (what is legal after winning, does XP accrue, can you die
after victory). Concluding now and adding a post-victory mode later is additive; shipping
"continue" first and tightening later is breaking.
This work also fixes an existing gap in the terminal-state machinery rather than inheriting it:
a non-battle party wipe (trap, deprivation) currently leaves the session in
EXPLORINGforever.Non-battle wipes route to
GAME_OVER. The delicate machinery is opened once, for both changes.Engine changes implied
Adventure.itemsand the_effective_itemsunion, at session start and in validationlocked, plus thegate-refusal rejection codes and the consume-on-success effect
Listener; the quest lifecycle refereecommands; the optional
sourcefield on the baseCommandPlayerViewadditions: active quests, journalSessionMode.VICTORY,AdventureCompletedEvent, and the non-battle-TPK routing toGAME_OVERvalidate_adventuregrows hard reference checks — item ids in conditions, caches, andconsequences against the effective catalog; monster, area, and dungeon/level references in
triggers and transitions; quest and objective ids in lifecycle references
Consumers
persistent journal panel (distinct from the ephemeral log); a victory screen on
SessionMode.VICTORY; the LLM narration prompt gains active quest state, recent journalentries, and authored steering — replacing or augmenting today's hardcoded tone hints.
quests panel joins the nav (an eighth target); gates and triggers place on the map the way
transitions do. Lint grows: dangling-reference diagnostics mirroring
validate_adventure;advisory lints for flag reads with no writer, trigger cycles, collision-prone
trigger/encounter pairings, and treasure rewards with no accompanying
AwardXP; and areachability class ("door requires a key that is not placed anywhere") scoped to the decidable
core — an id appearing in no cache, no trigger consequence, and no purchasable catalog entry —
and advisory forever, because trigger-granted items and random treasure make the general
problem undecidable, and lint warns while only
validate_adventuregates publish.Delivery order
slice that ships the interpreter also converts the worked example:
examples/tui_crawlerregisters the library interpreter and authors its fetch quest as data in its adventure
document, and the hand-rolled
quest.pylistener is deleted. Its job was proving theextension surface sufficed; once the library ships the interpreter, its job is demonstrating
the authored surface, and keeping the hand-rolled version would leave two quest patterns in
the repo. Closing deliverable of this phase: file the osr-forge issue recording the
overrides-vocabulary gap (see the forge seam).
registration, journal, victory, LLM context.
The first slice is gates on doors and transitions. It is the right first slice not because it is
small and independent but because its tiny surface area forces the two hardest schema questions —
item identity and lock composition — while everything else is still cheap to change, and it
pressure-tests the condition vocabulary before quests build on it.
Seams and scope guards
NPCs: seam only, not folded in
osrlib's spec puts dialogue in the same belongs-to-the-game exclusion as quests, and an NPC model
without dialogue is just prose — which narrative blocks already carry. The engine already has
NPC-shaped verbs (
SpawnNpcParty— whose docstring names quest listeners as an intended caller —and
Parley); what it lacks is persistent named NPC entities, which nothing here requires. Theseam commitments:
spoke_to(npc_id)ornpc_presentarrivelater as additive variants.
speakerstays free prose now; a typednpc_idarrives alongside it additivelywhen NPCs land. Prose is never retrofitted into a reference.
quest source, no "monsters" as the only encounterable thing).
Adventure hooks
hooksstays exactly as it is: inert, diegetic rumor prose. With quests carrying offernarrative, the document has two "why the party goes" surfaces, and the overlap was examined
deliberately: they are different registers — rumors are town color a game renders
atmospherically, offers are the mechanical entry beat of a specific quest — and neither subsumes
the other. No unification, and no mechanical linkage from a rumor to a quest id; if a typed
linkage ever wants to exist it arrives additively, under the same rule as
speaker(prose isnever retrofitted into a reference).
Forge-backed projects
Quest authoring is native-project-only at first. In a forge-backed project the editor writes
overrides.yaml— osr-forge's schema — and that schema has no quest surface; growing one isosr-forge's decision, made in its own repo against a settled osrlib schema. The editor disables
quest surfaces in forge-backed projects with an affordance explaining why. No shim is built:
this is sequencing, not accommodation, and the osr-forge issue recording the vocabulary gap is a
closing deliverable of the osrlib phase, not a someday.
Content packs
Quests are adventure-scoped and stay out of packs. The deferral has a sharp edge the v1 scoping
exists to blunt:
ContentPackEntryembedsFeatureSpec, trap, and treasure directly(
crawl/content_pack.py:104-107), so a gate on those specs would ride into arbitrary targetadventures — carrying dangling item and flag references — the same release it shipped. Hence
gates v1 lands on
DoorSpec/TransitionSpeconly (which packs do not carry), andTrapSpec.bypassplus prose variants ride the pack-interaction decision when it comes. Packfeatures referencing item ids reference the shipped catalog only — adventure-bundled item ids
are adventure-scoped by construction, and pack insertion validates references against the target
adventure's effective catalog.
Serialization and versioning
Everything here is additive and nothing bumps
SCHEMA_VERSION:requires,Adventure.items,Command.source, narrative blocks) andnew event types are within-version by written policy (
versioning.py:30-38).SessionMode.VICTORYvalue is additive-within-version, and this work codifies thatrule into the versioning policy: a new enum value has the same risk profile as a new event
type, which the policy already blesses — old artifacts never contain it, and an older reader
rejecting a newer artifact is a possibility the new-event-type rule already accepts. (The
existing docstring says changing wire values is a bump; adding one is now addressed in
writing, so the next addition does not reopen the question.)
The design of the shared single
SCHEMA_VERSIONinteger itself — one number across saves,characters, parties, packs, and stamped documents — is out of scope here; it deserves its own
issue if a bump-forcing change ever approaches. The editor's canonical-serialization
byte-stability invariant must hold for documents that use none of the new fields, and its
open-fields fidelity guard already protects older editors from newer documents in the interim.
Commitments at a glance
Adventure.itemsbundles existing item templates into the effective catalog,mirroring monsters;
ValuableSpecstays id-less.DoorSpec/TransitionSpec; conditions arehas_item(any member's carried inventory, optional consume-on-success),
flag_equals,effect_active(kind).PickLocknever bypasses a gate;SetDoorStatenever touches
requires; an open door admits passage; distinct gate-refusal rejection codes.consequences dropped and recorded, treasure rewards follow normal valuation with authored XP
via
AwardXP.Listener; all state engine-owned and command-mutated (lifecyclereferee commands); optional
sourceprovenance on every command.completion; rewards issue immediately on completion.
PlayerView.as content.
variants engine-resolved.
SessionMode.VICTORYplusAdventureCompletedEvent; the session concludes; thenon-battle-TPK gap routes to
GAME_OVERin the same work.examples/tui_crawler/quest.pyis deleted in the slice that ships theinterpreter; the example authors its quest as data.
Adventure.hooks: kept as inert rumor prose; distinct register from quest offers; nomechanical linkage.
SCHEMA_VERSIONbump; the enum-value-additive rule getswritten into the policy.
Non-goals
Parleyalready doesKey references
crawl/adventure.py:47-67(Adventure),crawl/adventure.py:94(_effective_monsters,the union pattern items mirror),
core/items.py:320(EquipmentCatalog and its four templatetuples),
core/effects.py:464(EffectDefinition, whosekindis the authorable effectidentity),
crawl/dungeon.py:183-196(DoorSpec),crawl/dungeon.py:214-228(TransitionSpec),crawl/dungeon.py:293-308(ValuableSpec — no id field),crawl/dungeon.py:365(FeatureSpec,whose
item_ids/magic_item_idsplace catalog items in caches),crawl/commands.py:95(SessionMode),
crawl/commands.py:1459-1714(referee commands incl.SetDoorState:1631,SpawnNpcParty:1596),crawl/commands.py:384(PickLock),crawl/events.py(
LocationEnteredEvent:94,ItemAcquiredEvent:202,MonsterDefeatedEvent:502,FlagSetEvent:660),crawl/views.py:134-156(PlayerView),crawl/session.py:802-804(theflags exclusion),
crawl/battle.py:542(the only terminal state),persistence.py:281-331(replay without listeners),
versioning.py:30-38(additive-within-version policy),crawl/content_pack.py:104-107(packs embed FeatureSpec),docs/spec.md:169-176(extensionpoints, incl. the lever/portcullis line),
examples/tui_crawler/quest.py(the hand-rolledlistener this design retires)
server/narrate.py(deterministic renderer),server/narration.py(LLM tone hints),server/library.py:49-127(raw-JSON metadata path),static/app.js:3234(ephemeral log)docs/spec.md:267(the quests future-extension entry),src/osreditor/ops.py(the op vocabulary),
src/osreditor/lint.py(the lint rule set)