# Unified Tavern loader contract [中文](LOADER_CONTRACT.md) The current contract targets Tavern **2.3.0** and DSH `0.1.5-rc.1`. It covers the RP session overlay (`selection.rp` + `rp:policy`), delegated subagents freezing their parent selection, named official sections, and schema 4 Trace references. DSH V3 uses `system/message` as the system-body authority while `request/header` retains config/tools; see the [migration contract](DSH_0.1.5_MIGRATION_en.md). ## Goals and ownership The loader is the only layer allowed to decide how current resources enter a DSH request. Format modules only interpret files. Use-case modules only manage resources. They must not register `systemPrompt`, mutate the Agent, copy conversation history, or write the model request themselves. ```text PresetModel ─────────────┐ CharacterCardModel ──────┼─> TavernProfileLoader ─> ordered Tavern sections UserModel ────────────────┤ │ WorldBookModel + matches ┘ │ ├─> agent/request call config SessionSelectionStore ─────────────────┘ ``` The root registers its logical profile at order 10, then expands it into `pmp-dsh-tavern:part:*` sections at the same array position. Import context retains `pmp-dsh-tavern:profile`; optional `rp:policy` remains at order 45. Preset `replace` keeps these Tavern contributions, including character/lore text and RP policy. ## Session policy The durable file is `session-selections.json` under the plugin data directory: ```json { "schemaVersion": 2, "sessions": { "": { "selection": { "presetId": "... or null", "characterCardId": "... or null", "userId": "... or null", "worldBookIds": [], "character": {}, "rp": { "active": false, "source": null, "followSuppressed": false, "sandboxBefore": null } }, "updatedAt": "2026-08-15T00:00:00.000Z" } } } ``` - Old `PresetStore.state.selectedId` remains the compatibility default for a session that is not yet bound. - The UI splits resource browsing from session binding. Changing the dropdown, importing, or creating only changes the current edit target. Only the explicit bind/unbind buttons write session selection. After UI/API carry `sessionId`, preset selection edits that session only and does not pollute other parallel sessions. - Presets share the same runtime boundary as character cards/users: changing a binding is rejected while the agent is running. Switching a preset on a session that already has history prompts that only later requests are affected. - A fresh ordinary session freezes the then-current default selection the first time an Agent uses it. - An ordinary fork copies the parent selection from `Session.header.parentSession`. After that, parent and child do not stay linked. - A subagent with `delegationDepth > 0` likewise freezes the parent selection (the same projection as **New chat with current settings**, including `rp`). After that, parent and child do not stay linked. Whether the delegated task is narrowed is decided by the parent agent's spawn prompt, not encoded in `rp:policy` or an empty selection. - RP is a session overlay on selection, not a DSH agent preset. `selection.rp` records whether it is locked. Optional `rp:policy` text lives in `rp-policy.json`. The default only says high-risk operations are locked. - When a resource is deleted, loader policy provides `clearResource(kind, id)` to clear every dangling selection. - Session id is only a JSON key, but it still goes through length/charset checks to avoid prototype keys and abnormal input. - Schema v1 is migrated in place to v2 on read. Character options keep only the three loader-known greeting/system/PHI fields. Resource ids and per-session world-book counts are bounded. - Default cap is 2,048 sessions (implementation hard cap 4,096) and 4 MiB of durable state. Old files over 8 MiB never enter `JSON.parse`. Writes validate on a copy and land atomically. Failure does not pollute in-memory state. - Selections are user intent that must not be dropped silently, so a full capacity rejects new entries instead of copying Trace's LRU. `deleteSession(id)` is an explicit reclaim seam; the current Host has no authoritative session-delete event that can trigger it automatically. ### Running-agent mutation boundary The current runtime protection is “explicit session-binding write protection”, not a global transaction lock over every resource change. Preset, character-card, user, and standalone world-book selection APIs query the matching agent before writing `SessionSelectionStore`. State `running` returns HTTP 409 with `PRESET_AGENT_RUNNING`, `CHARACTER_AGENT_RUNNING`, `USER_AGENT_RUNNING`, or `WORLD_BOOK_AGENT_RUNNING`. That stops a user from switching those four selections on that session through the normal bind buttons while a turn is executing. These indirect mutation entries are outside the same protection, so the current contract does not guarantee that “a running Tavern configuration is fully immutable”: - session-template/configuration apply can overwrite a target session with a complete selection. The normal UI targets a newly created blank session, but the API itself does not yet reject an already-running existing target. - Deleting a referenced preset, character card, user, or standalone world book calls `clearResource()` and clears one or more session selections without checking each affected agent. - Editing the body of a currently bound resource does not change the resource ID, but it does change what a later assembly reads. - Editing a user, preset, or character card's standalone world-book relations may change the effective world-book set of one or more sessions. Today only the relation and resource caps are validated. Those sessions are not checked for running. A completed system assembly is a frozen snapshot. Resource changes do not write back into durable history and cannot be described as having entered an earlier request. Concurrent edits around assembly retain a timing boundary: body edits and indirect relation changes only guarantee that a later assembly reads the state visible at that time. The current running-state rejection guarantee applies only to the four explicit selection writes above. ### Clean-session/template policy **New chat with current settings** and configuration templates copy only the selection projection above. They do not call an ordinary fork and do not read or write Session events. DSH mode obtains a real blank session id through public `workspaces.connectWorkspace(workspaceId)`. Mowan first obtains the configured character from the same preview, then calls the shared playthrough-create controller: existing v2 session/directory/timeline/catalog atomic APIs create or reuse that character's authoritative empty playthrough; v1 apply then commits the complete configuration with one `SessionSelectionStore.set(targetId, completeSelection)` and rechecks that the root session's character binding matches the playthrough character. Both modes call public `sessions.open(targetId)` only after success. A configuration with no character card can create only an ordinary DSH session, not a playthrough. Templates are not rewritten silently when a resource is deleted. Dangling ids for preset, character/greeting, user, or standalone world book are returned as structured diagnostics from preview/apply and block create. A DSH create failure happens before the selection write. An atomic write failure does not publish in-memory state and does not navigate. Templates must not contain durable history, Trace, Inbox, turn/step, runtime state, or resource bodies. RP state is copied with the selection projection. ## Profile safety budget `TavernProfileLoader` applies a default 512 KiB UTF-8 cap to the combined Tavern profile text it generates. `limits.maxProfileBytes` may tighten or loosen it, but the implementation hard cap is 2 MiB. The world-book parser/store share a streaming structure guard before normalize: at most 10,000 entries per resource, depth 32, 100,000 nodes, 1 MiB per string, 1,024 characters per object key. The adapter additionally applies a 10,000-entry hard cap to standalone plus embedded books for this request. A resource that cannot fit is skipped and diagnosed. The combined budget is first-come by a deterministic composition order: session-explicit standalone books, user-bound standalone books, preset-bound standalone books, character-bound standalone books (stable ID de-duplication), then the card's embedded book. Each resource is reserved as a whole; if it cannot fit completely it is not scanned. So when earlier standalone books fill 10,000 entries, the embedded book is skipped with `WORLD_BOOK_RUNTIME_TOTAL_LIMIT`. That is an intentional safety/determinism policy, not a random omission. After those guards, the assembler considers at most the top-ranked 4,096 lore candidates and limits raw lore bodies to twice the profile budget before composing section text. A world book's own `tokenBudget` and `ignoreBudget` only decide ST-compatible candidates. They cannot change any Host hard cap. When a character card edits an embedded `character_book`, the shared structure guard and parser run first. Raw JSON/PNG import only confirms at the character-format layer that `character_book` is an object, then losslessly keeps unknown fields, and does not run the same depth/node/entry guard before disk. The 32 MiB import cap limits total input. When the loader first consumes it, `parseCharacterBook()` fails closed with `EMBEDDED_WORLD_BOOK_INVALID`; an unrunnable embedded book can enter the library, but it cannot enter the match-amplification path. If all content exceeds the cap, the assembler keeps the highest-ranked lore-entry prefix that still fits in the original candidate order and reports `TAVERN_PROFILE_LORE_LIMITED`. If it is still over after removing all lore, it throws `TAVERN_PROFILE_TOO_LARGE`. Preset, character fields, or user description are not truncated in the middle. ### Resource-to-world-book relationships User resources stay strictly `{ id, name, description }`. “User-bound world books” are an independent relation in unified loader policy's `user-world-book-bindings.json`. World-book ids are not written into the description body, and the `user` adapter does not run the matcher itself. Preset and character standalone world-book relations live in `resource-world-book-bindings.json`, partitioned by owner kind `preset` / `character`. IDs are not added to the ST preset or character-card original. A card's embedded `character_book` stays in the card and exports with it. External relations are a parallel, not exclusive, source. On each assembly the loader reads the current session's explicit `worldBookIds`, current user relation, current preset relation, and current character relation, de-duplicates by ID, and hands each standalone book to the shared adapter once. The card's embedded book then enters the same adapter. Audit keeps the original `sessionSelection` and `worldBookSelection` explicit/user/preset/character/effective/duplicate IDs. Each world-book resource summary's `bindingSources` keeps every hit source. The active view's `selection.worldBookIds` is the actually effective set, so the launcher can show the real combination. Unbinding or switching any one resource removes only that source and does not rewrite the others. Deleting a user, preset, or character card clears owner relations and the matching session selections. Deleting a standalone world book clears every relation and session-explicit reference, but does not modify any card's embedded book. The relation store bounds owner count, books per owner, state bytes, and safe reads, and atomically replaces after validating a copy. ## Adapter boundary `TavernProfileLoader` exposes one singleton adapter slot each for character, user, and world book: ```js loader.registerCharacterAdapter({ resolve({ selection, sessionId, agent, conversationText, context }) { return { character, diagnostics } }, }) loader.registerUserAdapter({ resolve({ selection, sessionId, agent, conversationText, context }) { return { user, diagnostics } }, }) loader.registerWorldBookAdapter({ resolve({ selection, sessionId, agent, conversationText, character, context }) { return { loreEntries, resources, diagnostics } }, }) ``` Constraints: - Adapters return already-normalized models. They do not return raw ST files as runtime instructions. - Adapters may read `conversationText` for matching. They do not write the session. - Adapters do not assemble the DSH system prompt. Final placement, override, de-duplication, and degradation diagnostics are decided by `compileTavernProfile()`. - Only one adapter per kind. A duplicate registration fails immediately so load order cannot decide behavior. - A disposer revokes only its own instance and supports HMR. The character adapter's minimum return model matches the character branch `CharacterCardModel`. The loader currently consumes `id/name/updatedAt/data`. The user adapter returns only `{ id, name, description }`. The world-book adapter at least normalizes activated items to `{ id|uid, content, position: "before"|"after" }`. ### Activation input contract The loader Host layer's only `PendingInputProjection` rebuilds the queue and this claimed batch from public `agent/inbox/spliced`, then gives adapters a structured, read-only `activationContext`: ```js { messages, // bounded { id, role, text, source } de-duplicated by stable id text, // matcher-compatible input under message/character caps metadata, // counts, truncation, claim event seq; no body / body hash } ``` `conversationText` is a compatibility field derived from `activationContext.text`, not a second state. Adapters consume that value only and do not subscribe to DSH events. Pending queue, claim/cancel decisions, one-shot consume on first assembly, turn-end cleanup, and de-duplication are exclusive to the loader. Default scan is the latest 128 messages / 64 KiB characters; hard caps are 1,024 messages and 1 MiB. Queue retention has its own message/character hard caps. Trace does not persist ActivationContext messages/text. Schema 4 also stores no assembly or source-body copy; it records verifiable official-history references and metadata only. ## Composition semantics ### Preset-only compatibility With no character, user, or activated lore, the loader calls `compilePresetForDsh()` directly. It emits enabled non-marker prompt bodies in their original order and preserves sampler mapping and macro behavior. Tavern adds no preset names, IDs, or XML-style identification wrappers to model-visible text; identical tags authored in resource bodies remain literal. A selected resource with no body produces no placeholder header. Official waterfall sections contain only `name` and `text`; prompt identifiers, requested roles, and resource provenance are kept in Tavern Trace metadata. ### Marker ownership After a character is selected or lore is activated, the unified assembler consumes these ST markers: | Marker / prompt | Loader source | Behavior | | --- | --- | --- | | `main` | character `systemPrompt` | May override the preset; supports `{{original}}`. `forbid_overrides` keeps the preset | | `worldInfoBefore` | active before lore | Emitted at that marker; stable fallback if the marker is missing | | `charDescription` | character description | Emitted once; fallback if the marker is missing | | `charPersonality` | character personality | Emitted once; fallback if the marker is missing | | `scenario` | character scenario | Emitted once; fallback if the marker is missing | | `personaDescription` | user description | Emitted once. `{{persona}}` may be an explicit placement. Missing marker/macro is diagnosed with a stable fallback | | `worldInfoAfter` | active after lore | Emitted at that marker; fallback if the marker is missing | | `dialogueExamples` | character message example | Emitted as ordinary approximate system text; provenance is recorded only in Tavern Trace metadata | | `chatHistory` | DSH Session | Marker is consumed but not emitted. DSH durable history is always the only authority | | `jailbreak` | character PHI | May override the preset; supports `{{original}}`. Position approximation is reported explicitly | Each character field, user description, and lore position is consumed at most once. `{{user}}` uses the name of the user bound to the current session. The user description may also use existing name/character macros. A user resource does not change the DSH Agent persona or identity section. Creator notes never enter the profile. Turning off the character system/PHI switch truly suppresses the field. It is not moved to fallback and sent by accident. ### Honest degradation - Greeting contributes plain system text only on the first-round generation. After the first real assistant reply it is no longer injected, and it is never forged as assistant history. - PHI lives in the Tavern system profile and is not claimed to sit strictly after all history. - The character module preserves depth-prompt role/depth fields. The current loader can only place the body in an ordinary system fallback and reports that real depth/role semantics were not executed. - `user`/`assistant` preset prompt roles remain reviewable in Tavern Trace `sources[].role` metadata. Official waterfall sections themselves contain only `name` and `text`; every actual contribution is still a system section, not a real history-message role. - System assembly scans durable history plus this step's claimed batch, so the current input of a single-step session can hit on the first request. The implementation does not use a too-late `agent/pre-step` and does not read a private Inbox. - Trace must describe the actually frozen assembly. It must not rerun the matcher on current input after `agent/pre-step` or `request/header` and label that result as having entered this turn's system. Because there is no same-step reassembly seam, the claimed batch must enter the matcher via the `agent/inbox/spliced` projection before the first assembly. ## Audit boundary `TavernProfileLoader.compile()` returns: - `systemText`: the Tavern profile proposed by the loader and expanded into named official system sections; - `callConfig`: supported fields actually proposed through `agent/request`; - `resources`: summaries of preset, character, user, and world book resolved this run; - `diagnostics`: missing resources and placement degradation; - `audit`: session selection, resources, activated lore IDs, and SHA-256 fingerprint. For DSH V3 sessions, official `system/message` and context `user/message` are authoritative for prompt bodies, while `request/header` stores final tools and call config. V0–V2 compatibility reading establishes an older `request/header.system` reference only after an exact full-body hash match. Loader audit helps UI/API explain why this input was produced. It replaces none of those official DSH events and adds no private session event. ## Adapter integration invariants The character-card adapter must: 1. store/API/UI maintain only character documents and selection intent; 2. migrate or bridge session selection onto `SessionSelectionStore.characterCardId/character`; 3. provide the model through `registerCharacterAdapter()`; 4. register no separate Host system section/profile assembler; 5. verify with marker, replace, dual-session, fork, subagent, and request-header tests. The world-book adapter must: 1. keep parser/matcher as pure logic; 2. return activated entries and diagnostics through `registerWorldBookAdapter()`; 3. card-embedded `characterBook` and standalone WorldBookModel enter the same matcher; do not reimplement; 4. do not register a system context/section yourself; 5. give deterministic tests for scan window, regex, recursion, and budget. The user-resource adapter must: 1. store documents are strictly `id/name/description`; reject avatar and unknown fields; 2. `SessionSelectionStore.userId` is the only session-binding owner; 3. hand off through `registerUserAdapter()` to the unified loader; do not register a Host seam; 4. marker, macros, fallback, and description de-duplication are executed only by `compileTavernProfile()`; 5. verify dual session, live switch, restart, unbind, delete cleanup, and a single final profile output. ## Current acceptance - Preset-only output and model parameters do not regress. - Two sessions may choose different presets, or explicitly choose “no preset”. - Ordinary forks and delegated subagents both inherit a snapshot of the parent selection (including RP state) and then stay unlinked. - Marker fill, character override, `{{original}}`, lore before/after, and chatHistory-not-copied all have unit tests. - `replace` removes only host system sections and keeps tools, contexts, variables, and the complete Tavern profile. - The API active view exposes selection/resources/diagnostics/audit and does not expose the full `compiledPrompt`. - Character-card APIs use unified session policy. Old `character-state.json` bindings migrate one way and are then cleared so unbind + restart cannot resurrect them. - V1/V2/V3 JSON and PNG cards can enter the profile through the adapter. Creator notes are not sent. - A card's embedded `character_book` uses the shared world-book parser/matcher. Hits enter the same profile. - Standalone world books are provided by the `world-book-library` use-case layer as a document store, CRUD/export API, and management UI. Per-session zero/one/many bindings still write loader-owned `SessionSelectionStore.worldBookIds`. - Selected standalone books and the card's embedded `characterBook` are run by the same world-book adapter through the same parser, matcher, rank, probability, and budget contract, then merged into the profile. - Deleting a standalone book clears dangling ids from every session through `clearResource("world-book", id)`. It does not read, modify, or unbind a character card or its embedded book. - User CRUD/API/UI, per-session single binding, `{{user}}`/`{{persona}}`, the `personaDescription` marker, and diagnostic fallback are wired. - The user panel can save zero or more standalone world books per user. The loader composes with stable order “session explicit first, user relation next”, de-duplicating. The active view/launcher/Trace publish the actually effective set. - User relations are independently atomically persisted, with caps on user count, books per user, state bytes, and safe reads. Deleting a user or world book clears the matching relation without deleting other users or session-explicit selections. - The world-book panel can save zero or more standalone world books for the current preset and character. Relations do not pollute ST originals. The loader composes with stable de-duplication in order “session, user, preset, character, character embedded”. - A bound card with no `character_book` can create an empty embedded book in the world-book panel and save it through the existing card embedded-book API. Standalone relations and the embedded book each keep their export semantics. - No local third-party preset, character-card, or world-book fixture has been copied.