Menu

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 a move. That is how an agent’s direct file edit becomes operations. All in @elabora/core ops/.
  • set of a key the object does not have yet appends it, or places it after the key named by after (null: first). The inverse of unset uses 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 id of 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/core commands/: they read a files snapshot and return the files they would leave, or the operations they mean when intent matters (a move is a move, not a reorder of its neighbours). They never write files themselves. @elabora/node edit/ 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 in plan-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 are agent, with the MCP client name. The CLI uses ELABORA_AUTHOR if set, claude-code when CLAUDECODE is set, codex when CODEX_THREAD_ID is (Codex CLI and desktop app), agent when stdin is not a terminal, else human. File changes that no Elabora tool made are external: in practice an agent editing files with its own tools. An agent of Elabora’s chat also carries its conversation (chat, from ELABORA_CHAT given 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 agent or external) 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 and system changes (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 in unavailable.
  • 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 (pruneProjectData prunes 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 write operations 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, visited conditions, go-to, layer and block actions), translation unit ids in translations/*.xlf (and the screen file named in their notes), and attribute selectors on data-section, data-screen, data-layer (unless the backdrop has a layer of that name) and data-block in the stylesheets of theme/ and components/;
  • elabora.lock.json keeps the element’s key under its new id (screen:, quiz:, numeric:, decision:, object:, block: and visit: entities), so learners keep their progress; elabora diff lists 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 (waivers in 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/core format/migrations/): an ordered chain of pure steps, each from one version to the next, on the raw files of a project (ProjectFiles in; 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.files are the migrated files, project.migration says from what, and I_FORMAT_OUTDATED lists what elabora migrate would change. A newer format gives E_FORMAT_NEWER and nothing past course.json is loaded.
  • Writing (@elabora/node migrateProject; elabora migrate [--dry-run]): one system change (migrate_format) after a backup version taken for the reason migration, so elabora undo or 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; with files, 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 carries formatMigration), 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.