Operations, journal and identifiers
On this page
How every change to a course becomes an operation with an author, a batch and an inverse; the project lock and journal; permanent ids, renaming and format migrations. Part of the architecture, the contract every change follows.
Operations, journal and undo
Every change to a project is a list of operations recorded in a journal. The app, the CLI, the MCP server, agents writing files directly, undo, backups restore, migrations and (later) collaboration all speak this one language.
Operations
JSON files are edited by path. A path is a list of segments: an object key, or { id } selecting the element of a keyed array (an array of objects that all have a distinct id, or an array of distinct strings, matched by value). Other arrays are values: they are replaced whole. Numeric indexes never appear in recorded operations.
type Segment = string | { id: string };
type Op =
| { op: 'set'; file: string; path: Segment[]; value: Json; after?: string | null } // path [] replaces the document; after: see below
| { op: 'unset'; file: string; path: Segment[] }
| { op: 'insert'; file: string; path: Segment[]; value: Json; after: string | null } // path: the keyed array; after: id, or null for first
| { op: 'remove'; file: string; path: Segment[] } // path ends with { id }
| { op: 'move'; file: string; path: Segment[]; to: { file: string; path: Segment[] }; after: string | null }
| { op: 'write'; file: string; text: string } // non-JSON text file, or a new JSON file
| { op: 'write'; file: string; blob: string } // binary file, by hash in the blob store
| { op: 'delete'; file: string };
applyOps(files, ops)is pure and returns the new files; JSON files are written with 2-space indentation and a final newline, key order kept.invertOps(before, ops)returns the operations that undo them, one per operation.diffFiles(before, after)computes operations between two snapshots, by id in keyed arrays, with the fewest moves; an element removed from one keyed array and inserted into another (in the same file or not) with the same id becomes amove. That is how an agent’s direct file edit becomes operations. All in@elabora/coreops/.setof a key the object does not have yet appends it, or places it after the key named byafter(null: first). The inverse ofunsetuses it to put the key back where it was, so that a file comes back exactly.- Ids are permanent here too: an operation never changes the
idof an element of a keyed array (paths, and the inverses that select it, would break). Giving an element another id removes it and inserts it again. - High-level commands (
add_block,move_block,create_screen…, the vocabulary of the CLI and MCP) are pure functions in@elabora/corecommands/: they read a files snapshot and return the files they would leave, or the operations they mean when intent matters (a move is amove, not a reorder of its neighbours). They never write files themselves.@elabora/nodeedit/runs them as changes. - The plan (
plan.json) is the working copy of the contract, written at any time (write_plan,set_plan_text,set_plan_voiceover,revert_plan_change); its versions inplan-versions/are made and validated by a person (create_plan_version,approve_plan: refused to an agent author,E_PLAN_PERSON_ONLY), and an agent’s write that changes a text kept « à l’identique » is refused (E_PLAN_VERBATIM_LOCKED). Commands know their author for this (CommandContext.author, D52).
Changes and authors
A change is one applied command: its operations, their inverse, the hashes of the touched files before and after, an author and a batch.
interface Change {
seq: number; id: string; at: string;
author: { kind: 'human' | 'agent' | 'external' | 'system'; name?: string };
batch: string;
command?: { name: string; args: unknown };
ops: Op[]; inverse: Op[];
files: { path: string; before: string | null; after: string | null }[];
unavailable?: string[]; // hashes of previous contents no store kept (see Binary files)
}
- Author. The app writes as
human. MCP calls areagent, with the MCP client name. The CLI usesELABORA_AUTHORif set,claude-codewhenCLAUDECODEis set,codexwhenCODEX_THREAD_IDis (Codex CLI and desktop app),agentwhen stdin is not a terminal, elsehuman. File changes that no Elabora tool made areexternal: in practice an agent editing files with its own tools. An agent of Elabora’s chat also carries its conversation (chat, fromELABORA_CHATgiven to its MCP server). - Batch. A batch is one piece of AI work, the unit of « Annuler le travail de l’IA ». An AI change (author
agentorexternal) joins the current batch when the previous change was also an AI change and less than 5 minutes old, and no human or system change, and no mark, came in between. Otherwise it opens a new batch. Human andsystemchanges (migrations, restores) are never part of AI work: each is its own batch. A mark (journal entry written when the user presses « Demander à l’IA » in the app) closes the batch and labels the next AI batch with the request. - A turn of Elabora’s chat is a mark with its
chat; the mark’s id is the turn’s. Every change of that conversation’s agent joins the batch named after the turn, whatever was written in between (the author’s edits, another agent’s) and however long the agent thought, until the conversation’s next mark: one turn, one batch, undone at once. Its lookup reads the journal backwards and goes back a day at most. An agent outside the chat never joins a turn; a file written by no Elabora tool joins the AI work just before it, a turn included. - Undo a batch applies the inverse operations of its changes, newest first. An inverse operation whose target was changed since (the current value is not what the batch wrote) is skipped and reported, so a human edit made after the AI is kept. Undo is itself a change (author: the human), so it can be undone.
- Binary files. Undo restores the previous content of a binary file when the blob store holds it (the journal stores what Elabora tools overwrite; backups hold what an agent overwrote with its own tools, if a version was taken before). Otherwise that operation is skipped with the reason
unavailable. A change that overwrites or deletes a binary file whose previous content no store kept (an agent’s own tools, before any version) is journaled all the same, listing that content inunavailable. - JSON files written in another formatting (by hand, by an agent’s own tools) are formatted when an edit touches them; their original text is kept in the blob store, so undoing the edit gives the file back exactly as it was.
Journal and lock
In the project’s data folder (Machine-local data; .elabora/ by default), through @elabora/node journal/:
journal/<yyyy-mm>.jsonl: one change, undo record or mark per line, append-only, kept 30 days (pruneProjectDataprunes it with the backups). The edit history of the prototype (.elabora/history/) is not imported.lock: every writer (engine, CLI, MCP) holds the project lock while it checks preconditions, writes files and appends to the journal. It is created exclusively and records the process id, the computer and the time. A lock whose process is gone is broken: at once when that process ran on this computer (a command stopped with Ctrl-C must not block the next one), after 30 s when it ran on another computer, whose processes cannot be checked. Errors:E_LOCKED.- Preconditions: a command computes its operations on the snapshot it read. Before writing, the writer checks that each touched file still has the content it read; otherwise it re-reads and recomputes (commands are deterministic), or fails with
E_CONFLICT. - Binary contents referenced by
writeoperations and by inverses live in the blob store (shared with backups); journal lines hold their hashes. The store’s garbage collection keeps every blob the journal refers to. - The CLI’s edits take an automatic backup version just before writing when the newest one is older than 5 minutes, then prune the project’s data.
Identifiers and format version
Renaming
Ids of sections, screens, layers, blocks and events are unique in the project (E_DUPLICATE_ID) and permanent: tools never re-identify an existing node. The screen id is its file name. Renaming is one explicit command, elabora rename [dir] <old> <new> (MCP elabora_rename; renameId in @elabora/core commands/rename.ts, run as a change by @elabora/node edit.rename):
- the node gets its new id: an element of a keyed array is removed and inserted again (operations never change ids); a screen file is deleted and written under its new name;
- every reference follows: section ids and the screens they list, events (trigger targets,
visitedconditions,go-to, layer and block actions), translation unit ids intranslations/*.xlf(and the screen file named in their notes), and attribute selectors ondata-section,data-screen,data-layer(unless the backdrop has a layer of that name) anddata-blockin the stylesheets oftheme/andcomponents/; elabora.lock.jsonkeeps the element’s key under its new id (screen:,quiz:,numeric:,decision:,object:,block:andvisit:entities), so learners keep their progress;elabora difflists the element as renamed, not removed. The LMS keeps the screen a learner left by its key too (lesson_location), so a learner who left on a renamed screen resumes on it;- waivers of the plan that name the id follow it (
waiversin the result): the plan’s diff shows the change; - refused when the new id is in use (sections, screens and their files, layers, blocks, events), is not a slug, or the old one names nothing or more than one element. Ids written in free-object code or generators are not rewritten.
Format version and migrations
course.json format gives the format version: elabora/0.1 (FORMAT in @elabora/format, whose registry and generated types follow the current version; its schemas are in packages/format/schemas/<version>/, and those of later versions will sit beside them). It is the first version: the chain of migrations is empty. From the next format change on, every change ships with its migration and a fixture course in the older format (test/fixtures/format-<version>/), tested to load, migrate to exactly the migrated example, validate and build; the mechanism is tested now with a step of the tests’ own (checkFormat(files, chain), migrateProject(dir, { steps })).
- Migrations (
@elabora/coreformat/migrations/): an ordered chain of pure steps, each from one version to the next, on the raw files of a project (ProjectFilesin; the changed files and notes of what changed, what was dropped and what was skipped out). A step edits the values it must in the text of each file (json-text.ts), so a file keeps its author’s formatting; a file that does not parse is left as it is, with a note.checkFormat(files)says whether a project is current, outdated (with the migration), newer or of a format this Elabora does not know (loaded as it is: validation reports it). - Loading an older project reads it migrated in memory:
project.filesare the migrated files,project.migrationsays from what, andI_FORMAT_OUTDATEDlists whatelabora migratewould change. A newer format givesE_FORMAT_NEWERand nothing pastcourse.jsonis loaded. - Writing (
@elabora/nodemigrateProject;elabora migrate [--dry-run]): onesystemchange (migrate_format) after a backup version taken for the reasonmigration, soelabora undoor a restore gives the course back as it was. A migration writes what the steps computed even where the files already had schema problems (validation reports them). It carries the gate record of a free object (object.gates.json) over to the object’s new fingerprint when the record held before and the migration changed nothing in the object but format strings, so passed gates stay passed (the fingerprint hashes the object’s bytes: node does it, not the core).migrateIfOutdated(dir, options)migrates only when needed and refuses a newer format; both take the executor’s options (files: an engine’s snapshot,validate,autoVersion; withfiles, the edit reads that snapshot with what the migration wrote); every edit through the executor calls it first (the migration is its own change, never part of AI work; the edit result carriesformatMigration), the library calls it on import (once the files and history are in place, before the project appears), and the engine calls it when it opens a project.