Docs / Developer / Scene state
Scene state
What a scene remembers beyond its text, kept so that every way of changing the shown history (a new version of a
reply, choosing a version, cutting, restoring, forking) leaves it where the history says. A piece of state is a
slot; what one reply version changed in it is a delta, stored on that version; the state is rebuilt from the
versions that are shown. The pure core is src/core/scene-state/; the stores keep it (ChatStore rule 17,src/stage/store/). The slots are vars, the scene's macro variables; the chat summary, summary; the state of each Props tracker module, tracker.<module>; and two slot families for the library trackers a scene links. Everything under "Not built yet" is
planned, not behavior.
Slots
A slot is one file, src/core/scene-state/slots/<id>.ts, that exports defineSlot({ id, scope, diff, default }).defineSlot refuses an id outside /^[a-z][a-z0-9.:_-]{0,63}$/ and a keyed-list with no key. bun run slots
writes src/generated/slots.ts from the folder (slots:check fails when it is stale, as kinds:check does for
kinds), and src/core/scene-state/slots.test.ts fails when a slot file is missing from the list, an id is repeated,
or the list is not what the folder would write. A slot may declare a shape (core/trackers/shape.ts), the rule every value written to it passes: the stores refuse a delta whose value does not fit, and a delta for an undeclared slot. The only scope is "scene": rebuilt from history. vars uses thefields strategy; each variable keeps the time it was first written and the time it was last written, which is
exactly a MacroVariable.
The Props trackers' slots
Each Props tracker module that keeps state (the twelve legacy trackers; the four Storyboards keep only a setting) has one slot, tracker.<module>, and they are not twelve hand-written files: slots/tracker.ts exports the list made from the module table in core/trackers/index.ts (a slot file may export a list whose ids start with its own name and a dot; the generator and the completeness test hold that). What of a module is STATE and what is a SETTING is split in one place, core/trackers/state.ts. The settings stay on the scene (chats.trackers_json: AI Context, the automatic-update choice, the sub-feature switches in subs, and a Storyboard's profile); the state is the items, values and rows a reply or the person changes. A module's StateSpec names its LISTS (a field that is a list of rows, each found by one field of its own) and its other FIELDS. A module with no list (Stats) is a fields slot, one delta per field. A module with a list is a keyed-list slot of wrapper rows { key, value }: a list row is { key: "items:<id>", value: <the item> } and any other field is { key: "currency", value }, so a reply that adds one item writes that one row, and cutting or regenerating it takes back exactly that row and nothing a later reply wrote. The slot's shape (or shapeAt for a fields slot) is the rule the module's own shapes give (core/trackers/shape.ts exposes a list's row shape and an object's field shapes for it), so a stored value and a model's value pass the same rule, and a row must carry the key its path names. The whole state's cross-field rules (a current value within its maximum) are the module's parse: a writer checks the whole next state before it cuts it into deltas. A cut, a middle delete, a swipe or an Undo can leave fields that each fit and together break one of those rules (HP past a maximum that went back, a leader who is no longer a member). No read ever puts the module's defaults in place of a state it can mostly keep: each module has ONE repair step in its read (repairOf, repairX in the module, used by readX and so by every reader and by trackerDataIn), which keeps every field and list row that fits its own rule, falls back to the default for one that does not, and brings the broken rule back inside (HP clamped to its maximum, a leader unset, an equipment slot naming a missing item dropped), the same "bring it inside, never refuse" rule as a tracker's numbers. The stored values stay as they were; every writer, the Director's plan and the panel start from the repaired state, and a write's base and expectation describe that state, so a write never carries a value it did not read. Only a state with nothing salvageable shows the module's empty start, and the panel then says so in a line. Rows keep the order they first appeared in; a reorder of a list is not a change this records. trackerDataIn reads a module's data from a state (the slot over the module's defaults, with the scene's switches), trackerOverlay does it for every module, and trackerDeltas cuts a change into deltas.
Slot families
A slot file whose id ends in :* is a family: it covers every id that starts with its prefix, so state made by a library piece at run time needs no file of its own and the store never sees library pieces. slotOf(id) (makeSlotOf in src/core/scene-state/slot-lookup.ts, bound to the declared slots in src/generated/slots.ts) gives the exact slot if there is one, else the family whose prefix the id has, with a member part of 1 to 120 characters (the family's own * is not a member). A family id is one lowercase word, a colon and a star (FAMILY_ID); its file is named without the :* (tracker-record.ts); two families whose prefixes overlap, or one declared twice, fail the generator and the completeness test. Two families exist: tracker-record:* (fields, one delta per field) and tracker-list:* (keyed-list, rows keyed by name). The slot of a linked tracker is tracker-record:<pieceId> or tracker-list:<pieceId>; the stores check a delta against the family, and the shape of a tracker's values is checked in src/core/tracker.ts (tracker.md), which knows the piece.
Deltas and the three strategies
A delta is { slot, path, value?, removed?, at }: a value at a path inside one slot, or its removal, never an
operation. Because it is a value, cutting a middle message takes back exactly what that message set and nothing
a later message set again. diff.ts cuts a change into deltas and fold.ts puts deltas back, by the slot's strategy.value keeps the whole value (path []). fields keeps one delta per field (path [name]). keyed-list keeps one
delta per row of a list, found by the row's key field (path [key]). fields holds the variables, keyed-list holds the summary and the linked list trackers; all three are
implemented and tested. A delta for an unknown slot, or with a path its strategy cannot read, is skipped by fold
and refused by the stores, never guessed at. A name such as __proto__ is an ordinary own property.
Storage
Migration 43 adds five tables and an index, schema only (branch_variables stays, unread; nothing moves out of it). swipe_deltas
holds one row per delta, keyed by swipe id and sequence, never changed once written: a fork copies messages but keeps
swipe ids, so a take shares its prefix's deltas and no delta row is copied. message_deltas holds what a message carries
whichever version shows (a person's edit on a reply), keyed by
message id: it belongs to one take, and a fork, or a cut restored as a new take, copies it with the new message id. A message's
own deltas fold after its shown version's, so a person's edit wins on that message. Migration 44 adds a nullable swipe_id to message_deltas: null is a person's edit, and a swipe id is a write made after a commit that names the version it is about (recordDeltas). Such a row belongs to this take's message, applies only while that version is shown on that message, folds after the version's own deltas and before the person's, and a fork copies it with the message (a fork inherits the prefix's writes; its own later writes stay in the fork). swipe_deltas is never appended to after a commit, since every take that shows a version shares it. branch_base is what a take starts with, before any message (an import's variables): real data, not a
cache, so a damaged cell refuses; a fork, or a cut restored as a new take, copies it, and the rebuild starts from it when no keyframe is nearer. branch_state is each take's tip cache, the
state after its last shown message, with the tip it was built for. state_keyframes keeps the full state after every
50th shown message of a take. Both are caches: every change to what a take shows rewrites them (appending a message changes
no state, so it does not), and rows the health check sets aside drop them, so a read builds the state from what is left
until the next change. A cache cell that will not open is skipped and rewritten, never fatal; a delta cell that will not open
refuses, because the deltas are the data. Delta paths and values, keyframes and the
tip cache are sealed like the other chat payloads; slot, removal flag and time stay readable. turn_checkpoints
gains a sealed deltas_json, the turn's pending deltas. The tables are covered by the row fingerprints, the health
check (cells, orphans, what a move sets aside: a version's rows go only with the last reply that holds its swipe id) and the
snapshot lists. An index on swipes.swipe_id keeps those lookups from scanning every version. A version carries as many deltas as its writers make:
there is no per-version cap, since a turn's writes are bounded by their sources (the model's output, the macro engine's output cap). Only
what can come from an import or a person's edit (a take's base, a message's own deltas) is held to a hostile-input ceiling of
1,000,000 serialized bytes for one write (checkImportedDeltas: it counts the incoming write, not what the message already carries), refused as unusually large. The memory store keeps the same data in maps, behind the same
contract suite.
Rebuild
The store owns the rebuild, inside the transaction of the change, so no caller can forget it. A rebuild starts from
the nearest kept keyframe on the path, else from the take's base, else from nothing, folds the deltas of the shown versions after it in order,
rewrites the keyframes it passes, and writes the tip cache. Keyframes at or after the first changed message are
dropped first.
| Change | Rebuilt from |
|---|---|
a reply or a new version is committed (commitTurn), a version is added (addSwipe) |
that message |
a version is chosen (selectSwipe, selectTranscriptSwipe) |
that message |
a version is cut or restored (cutSwipe, restoreSwipe) |
that message |
messages are cut or restored (cutMessages, restoreCut) |
the first message cut or restored; a restore as a new take also builds the new take |
a take is forked (fork) |
the new take, from its own path |
| a take is deleted or restored | dropped, or rebuilt |
recordDeltas (a write after a commit that names a version; the same optional guard, judged against the state as if that version were the one shown): the rows are kept with this take's message, tied to the version |
that message, in that take only |
recordMessageDeltas (optionally guarded by the branch version the caller read and by expect, the values it read at each slot and path, never stored: a stale version or an expectation the message no longer shows refuses and writes nothing) |
that message, in that take only |
| a continue is committed | nothing: it stamps no delta |
A turn that fails, stops with nothing or is cancelled has stamped nothing: its deltas wait on the checkpoint and go
with it. sceneStateBefore(branchId, messageId) answers the state a new version of that message, or a continue of it, builds from: everything shown before it, and its own message deltas. sceneDeltas(branchId, fromMessageId, toMessageId?, slotPrefixes?) is the range read for a reader that wants to see what each message changed: the state before from (everything shown before it, without its own deltas, so a person's edit on it is still a change) and each live message from from through to (the tip when omitted) with its deltas in fold order, its shown version's then its own. Given slot prefixes, only those slots' deltas are loaded (the state before is still whole). It builds the state with the same rebuild and reads the deltas with the same read as the rebuild does, and it changes nothing. A range that ends before it starts is empty; a branch that is absent or deleted, or a message that is absent, cut or on another take, answers null. The tracker changes read (tracker.md) is its one caller.
How a turn uses it
loadTurn reads the variables from sceneState for a new reply and from sceneStateBefore for a new version or a
continue, so a regenerate never counts its own earlier writes twice. Assembly returns the turn's local variable
changes as before; scene-turn.ts turns them into vars deltas (vars-bridge.ts) and saves them on the checkpoint
with the prompt trace. That save is awaited and may wait up to 30 s for the library's write lock; if it fails the turn fails before
anything is sent, as the old variable write did. commitTurn stamps the deltas on the version it stores and rebuilds the take. A continue makes no
deltas. A turn's deltas are always saved; nothing is refused for size.
The library's global variables ride the same pending set (see "Global variables" below): nothing is written before the request goes out, and a turn that
does not commit has written none. A tool's writes join that pending set while the reply streams: the trackers' update_trackers tool (tracker.md) and the skills' open_skill and close_skill (skill.md) add their deltas through the turn's one door (src/studio/scene-turn-writes.ts), which takes the library tracker slots and the skills slot only, has no size cap, and saves the set on the checkpoint, awaited (a save that did not happen, such as a checkpoint that is gone, is a failure the model is told of, with nothing kept); the tool loop runs them as its non-read tools (access: "write" with stateScoped: true, tiers narrator.tracker and narrator.skill). A picked skill closed by its close rule is closed by the turn itself, with its variable deltas. A swipe, regenerate or cut takes those writes back with the version, and a turn that fails or is dropped has stamped nothing. Other readers: the Context wing's variables route reads the vars
slot, and a chat import writes its variables as the take's base with setBranchBase, with or without messages, so no greeting, cut or
fork changes what the chat started with; the first reply's prompt sees them. A write of a value the
message already shows adds nothing: it is judged against the state through that message, its shown version's deltas included, so a person's edit back to the value the take held before a reply's own change is kept, and one equal to what the message shows is not recorded. The person's edit of a linked library tracker is the one caller that writes a tracker slot this way: POST /api/chats/tracker-edit builds the deltas with applyTrackerUpdates and records them on the take's tip message (tracker.md). The variable history route reads the recorded changes of the newest 400 replies (HISTORY_SCAN).
The Props trackers
Every writer of a module's state goes through the two doors. The turn's own writes are pending on the checkpoint and stamped in commitTurn (the narrator's update tool and the Spellbook's tick each have their own door onto that set, and neither door takes the other's slots): the Spellbook's tick (the cast it carried is spent, otherwise every cooldown counts down) is computed in the turn from the state the reply was built on (scene-turn-ticks.ts, afterReplyDeltas) and added to the checkpoint's deltas when the reply lands, never on Stop or Continue, so a new version builds from the state before the reply it replaces and ticks once (a cast recorded on that very reply is for the reply after it: leaveCastsOnTarget takes it out of the entries the turn loaded, so neither the prompt nor the tick sees it, and it stays pending), a cut takes the tick with the reply, and a turn that fails leaves the cast waiting. The prompt reads each module's data from the state the turn loaded (trackerOverlay: the state before the reply for a new version, the tip for a continue). The Story Director's background round names the reply it is about and writes with recordDeltas (scene-director-trackers.ts): the update is tied to that version in this take, applies only while it is shown, is copied with the reply by a fork and is never appended to the version's own deltas. It names what it planned against (expect), so a change made since refuses it and the module's update is dropped; and a field or row the person edited on that same reply is theirs (the person wins): the update leaves it, and the Director's activity says "Tracker update for left your edit of as it is". The module's read repair never overrides the person either: when a field the Director would write changes what the repaired read gives for a field the person edited (a lower maximum cutting their hit points), the module's whole update is not made, with a line such as "Tracker update for resources was not made: maxHp would change your edit of hp". An update with nothing left to write is not made. The round has no undo of its own and no tracker-side record, and nothing waits on a swipe, a cut or a fork. The person (a panel edit, Cast, Ready now, Cancel cast, Clear, an item edit, Undo) writes recordMessageDeltas on the newest reply (POST /api/chats/tracker-state, studio/tracker-state.ts), so it stays through swiping and regenerating that reply. A write is the change from the data the panel computed it from (base, what the panel saw after the module's read repair): the server reads again and, when its own repaired view at those paths differs from base, answers that the chat moved on; otherwise it writes only the fields and rows that changed, computed from the repaired state, and carries what it expected to find at those paths in the STORED values, so a field the repair adjusted can still be edited, a change made between the read and the write is refused by the store, and another writer's change elsewhere is kept (an expectation of a field at its default is "absent", since a default is not stored). A module whose stored state cannot be read at all shows an empty start, and the person's next change replaces what was saved. Several modules in one write are one store call, and Cast's Stats and Spellbook writes are both in it or neither is. Anything the person clicks or accepts (an accepted Refresh, Generate or card Refresh included) is that same person write; only the Story Director's automatic rounds are generated writes. The Undo of the last update a reply's version made to a module writes the module's data from before it as the person's own edit on that reply (so it holds through a swipe), only while the module still holds what the update left. The range read tells the layers apart (generatedFrom, personFrom), which is how the update line and the person-wins rule find what is whose. A cell of chats.trackers_json written before this change is read as settings alone (its sub-feature switches filtered to the ones the module has, so a stale switch never makes the state unreadable); existing scenes' tracker state is not moved.
The chat summary
The skills slot (slots/skills.ts, fields, one field per skill id holding true while open; writers turn and person) keeps which linked skills the model has open (skill.md).
The summary slot (slots/summary.ts, keyed-list keyed by version, with a declared row shape) is the take's saved summary (ChatStore rule 45; the Memory tab and the "Include saved summary in AI context" switch read it through summaryOf, and a turn reads it from the state it loaded, so a new version of a reply never sends its own earlier piece). It has two layers that never write the same path (src/core/summary/rows.ts):
- Generated pieces are rows keyed by the swipe id of the version they end at:
{ version, text, through, at }. The writer (src/studio/scene-summary.ts) writes each one after the reply has landed, throughrecordDeltas, tied to that version of that message in this take. An Every-exchange line is the row of the reply it summarises, so a new version replaces the line (numbered from the rows before it), choosing a version shows that version's line, and cutting the reply takes it. A Rolling recap, a Growing timeline or a Backfill is a block, flaggedreplace, that ends at the newest message (Every-exchange Backfill: the newest reply); it removes the other generated rows and adds its own on that version, so regenerating or cutting that message gives the earlier rows back exactly. Generated content is never written at message level. The key is the swipe id, not the message id, so a fork's copy of a message finds a reply already summarised. - The person's text is one row,
person, written as a person's edit of the take's newest message (setSummary, throughrecordMessageDeltas), so it survives choosing another version of that message and goes only with the message. Every save carries a fresheditid and the next numbernalong the chain of saves (a number, not a list).
Each generated row records, in saw and sawN, the edit and n of the person's text its writer read (none when there was none). That is the whole ordering between the layers, and no clock takes part in it. The take's text is the person's text, then every generated row it does not cover. A row that saw an older text (a smaller n) or none was written before the person's save and is covered by it, however many there are, so a save is one small row and nothing in it grows with the number of rows. A row written after the save saw it and shows, which is why clearing (empty person text) followed by a new version shows the new line. A row that saw a text with a larger n also shows: when the newest save's message is deleted, the older save comes back and the lines written after the newest still show, and restoring the message brings the newest back. A replacing block that shows stands in for the person's text; one that saw an older text (the person saved while the model was working) is covered, and the person's text stays. A run also writes with the generated rows it removes as its guard (expect), so it is refused, and the run reports that the summary changed, when another writer changed one of them since it read; that guard is judged against the state through the version written, which is why it names only those rows (the person's text may sit on a later message). A fork keeps the rows of the messages it kept and then writes its own; its writes never reach the source take and the source's never reach it. through is a count of the take's live messages made when the piece was written; cutting a message earlier does not move it. A summary needs a message to sit on: Write summary on a scene with no message is refused with a sentence. The old per-take text in chats.summary_json is not read; nothing carried over.
Global variables
The library's global variables ({{getglobalvar}}, {{setglobalvar}}, {{incglobalvar}} and the rest, and a lore entry's global Side Effects) are derived from every scene at once (src/core/scene-state/globals.ts). A reply's global writes are OPERATIONS on the version that made it, rows of the shared slot global (slots/global.ts, scope shared, a keyed-list keyed by the operation's id, row shape checked by the stores): set, inc, append or delete, stamped with the version in commitTurn like any of its deltas, so a turn that fails or is dropped has written none, and a continue writes none of its own. A take's own fold skips a shared slot. A global's value is the fold, in write order (time, then version, then the order written), of the operations of every version shown in at least one live take: ChatStore.globalOps gives them, each once however many takes show its version (a fork keeps swipe ids, so a shared version is one row), and leaves out a version that is cut, or whose message is cut, or whose take or scene is deleted. Regenerating, choosing another version, cutting, deleting a middle message, restoring and forking therefore all move the globals by exactly the writes they show or hide, and two scenes that each add one both count. A new version of a reply builds from the globals without the version it replaces (globalOps(except)), so it never counts its own earlier writes twice; the draft tools and the Context wing's variables route read them too. Operations, not values, on purpose: across scenes there is no shared text to keep consistent, and increments must add up. Each operation is the write as it was made: the macro engine records every global write in order (MacroContext.globalWrites; an add, increment or decrement carries the amount it added, MacroSideEffect.add), and a lore entry's global Side Effects record theirs the same way, so {{setglobalvar}} is a set (null included), an increment, decrement or numeric add an inc by its amount, a text add an append, a delete a delete. An add onto a value of another kind (numeric text, null, an object) is kept as a set of the value it made, so folding always gives what the reply's prompt saw; the array macros (push, pop, shift) write the whole array, so they are sets, and two scenes that push at once keep one push. A stored row that is not an operation refuses the read with a sentence, since every later value depends on it. The StateStore variables document is no longer read or written, and nothing was moved out of it. SQLite reads the slot through a partial index (migration 45); 10,000 operations over 100 scenes read in about 110 ms (most of it opening sealed cells) and fold in about 5 ms. No cache: the value is always folded from what is shown.
Who may write
Every slot names who may write it (SlotDef.writers, core/scene-state/types.ts SlotWriter), and every store door is one writer, decided by the door and never taken from a request: a turn's checkpoint (stamped at commit) is turn, recordDeltas is background (the Story Director's round, the chat summary's writer), recordMessageDeltas is person, setBranchBase is import. A delta whose slot does not name the door's writer refuses the whole write with a sentence ("the summary slot does not take writes from a reply") and writes nothing (checkDeltas in stage/store/scene-state-rules.ts, both stores); a slot that names no writer takes no write. Today: vars takes the reply, the person and an import (no person path writes it yet); summary a background writer and the person; a Props tracker the reply (the Spellbook's tick), a background writer and the person; a library tracker the reply (its update tool) and the person; global only the reply. Who may call each door is held by scene-state-guard.test.ts: recordDeltas only by the summary's writer and the Story Director's round, recordMessageDeltas only by the person's own writes (and setSummary, which reaches it inside the store, only by the summary's own save), and delta rows are inserted only by the SQLite store's state file and a fork's copy of a message's rows. Two separations are kept above the store, not by it: the writer classes are the doors, so background covers both the Story Director and the summary's writer (which keep to their own slots only because the guard limits who calls recordDeltas), and turn covers a model's tool, which is held to library tracker slots by the turn's one write door (scene-turn-writes.ts), not by the store. The Compendium's filing and historian write lorebook documents, not deltas, and check each book's access level before writing (canWrite, held to Kit's tiers by scene-tiers.test.ts), so they need no slot. Every write is a delta on the version that made it, so a wrong write is undone by a swipe or a cut.
Not built yet
Planned, in .claude/plans/scene-state.md, and absent from the code: lorebooks copied on fork (built and taken back out on 2026-10-28: a Compendium's settings belong to the scene while a copy would belong to one take, the Compendium wing reads books by their own id, and copies would gather in the Library, so the design waits on those decisions), a person's own edit of a global variable, the extension contract (an extension's own ext: slots, grants to read or write other slots on Kit's tiers, held for re-approval when widened, and a writer class for it): it waits for an extension runtime and its manifest, and a per-slot tier a writer needs, the Story Director updating library trackers, hand edits of any slot other than a library tracker's, a Props tracker's and the summary, and a state inspector. recordMessageDeltas is the
store's door for a person's write: its callers are the person's library tracker edit (POST /api/chats/tracker-edit), the person's Props tracker write (POST /api/chats/tracker-state) and the person's summary text. recordDeltas is the door for a write that names a version after its commit; its callers are the chat summary's writer (scene-summary.ts) and the Story Director's tracker round (scene-director-trackers.ts), and a slot's declared shape is checked on every delta either door takes.
What is tested
src/core/scene-state/scene-state.test.ts holds the unit proofs: each strategy's diff, fold with removal markers, a
rebuild from a keyframe equal to one from nothing. The contract suite
(chat-store.contract-state.ts) runs for both stores: choosing, cutting and restoring versions, cutting a middle message
and restoring it, a restore as a new take, a fork, a dropped, committed and continued turn, refusals, and a
2,000-message rebuild with a delta on every hundredth message (held to the same 200 ms budget; measured once at about 20 ms on
SQLite and 5 ms in memory on the development machine). chat-store-state.sqlite.test.ts adds the file-only facts: shared delta rows, the keyframes a mid-path cut leaves,
sealing, a refused commit, and the dense case, 6,000 deltas over 2,000 messages with 2,000 fields and 40 keyframes, a full
rebuild measured at 110 to 150 ms and held to a 200 ms budget. A busy-library test (scene-turn-busy.test.ts) runs a real turn
with the write lock held as its first save goes out. scene-turn-lore-effects.test.ts runs the turn:
a prompt's {{incvar}} counts once however often it is made again, and a continue, a failure and a Stop leave the
variables alone. scene-state-guard.test.ts fails when code outside the SQLite chat store names the tables or calls
the removed variables and changeVariables methods. chat-store.contract-scene-deltas.ts holds the range read to both stores: the state before and the deltas in fold order, a person's edit on the first message of the range, another version shown, a cut message, an empty range, a message of another take, and a copy. The slot families are held by slots.test.ts (exact beats family, overlap refused, the generated lookup) and one contract test that runs a tracker's record fields and list rows through versions, a regenerate, a cut and a restore on both stores; the turn-level proofs are scene-turn-linked-trackers.test.ts.
The Props trackers are held by chat-store.contract-tracker-state.ts on both stores (every module that keeps state has a slot and a delta the module would not accept is refused whole; a new version of a reply does not count its own earlier write twice; choosing a version shows its state; cutting a middle message takes back only the rows it wrote and a field a later reply wrote stays; a fork holds the state at its point and its own writes stay in it; a failed turn leaves nothing; the person's edit stays through a swipe; a write after the commit shows only while its version does and is read as its own layer; one write of several modules is all or nothing), core/trackers/state.test.ts for the cutting into deltas and back, scene-turn-trackers.test.ts for what a turn sends and the Spellbook's tick through the real turn (a new version ticks once, a cut takes it, a continue does not run it, a failed turn keeps the cast), and scene-turn-director-trackers.test.ts and scene-turn-director-trackers-versions.test.ts for the round (written on the reply's version, row by row, the person's own edit on that reply left theirs with a line in the activity, a change made elsewhere meanwhile dropping the update, a swipe, a cut, a fork, the Undo surviving a swipe). The chat summary is held by chat-store.contract-summary.ts on both stores (the person's text and its layer: a choice of version, a cut and a restore, a clear followed by a new version's line, and the chain of saves when the newest save's message is deleted and restored; recordDeltas on a shown and an unshown version; a fork inheriting the prefix's writes while its own never reach the source and the source's never reach it; a replacing block giving the earlier rows back, with and without the person's text; a block that ends before a user message carrying the person's text; a text saved after a run read, with the store's clock made to disagree with the studio's; the guard and the refusals, including a row of the wrong shape), rows.test.ts for the layers, the shape, and a take of fifty thousand rows covered by one small save, scene-summary.test.ts for the writer against a scripted model on both stores (a new version replaces its line, a fork's copy is already summarised, a continue adds nothing, Write summary with no message is refused), scene-turn-chat-summary.test.ts for what a turn sends (a new version of a reply does not send its own earlier piece; a turn that fails through the turn runner leaves no row, and one that lands writes its line), and a snapshot restore test for the version-tied rows. scene-state-guard.test.ts holds recordDeltas to its allowed callers (the memory and SQLite chat stores, their contract files chat-store.contract-summary.ts and chat-store.contract-tracker-state.ts, the summary's writer scene-summary.ts, and the Story Director's tracker round scene-director-trackers.ts) and fails when code reads a Props tracker's state from the scene's settings. tracker-state-repair.test.ts holds the person's write over a repaired state on both stores (a cut that repair adjusted, then hit points set and read within the maximum; a party leader set again; an unreadable module's next change landing; a change between the read and the write refused).
The global variables are held by chat-store.contract-globals.ts on both stores (two scenes that each add one both count, a take's own fold never shows the shared slot; a new version builds without the version it replaces and, once it lands, that version's writes stop counting; a version a fork shares counts once; a cut, a deleted take and a deleted scene take their writes with them and a restore brings them back; null is kept; a turn that never commits writes nothing) and by globals.test.ts (each write kept as the operation it was, a set staying a set when an earlier reply is cut, concurrent increments and appends adding up and two sets of one flag setting it once, a delete restarting a variable, null kept, a malformed row refusing the read, and 10,000 operations folded); core/macros/global-writes.test.ts holds the engine to recording each global write as the operation it was. scene-turn-lore-effects.test.ts runs a lore entry's global Side Effects through the turn: counted once however often the reply is made again, none from a turn that does not commit.
Who may write is held by chat-store.contract-writers.ts on both stores (a reply's checkpoint cannot write the summary; a background writer, a person's edit and an import cannot write the globals; an import cannot write a tracker and a write with one refused delta writes none of it; each door still takes what its slots allow), by slots.test.ts (every registered slot names its writers) and by scene-state-guard.test.ts (who may call recordDeltas and recordMessageDeltas, and the only places that insert delta rows).