UmweltDocs
Reference/Timelines and drafts

Timelines and drafts

A project has one confirmed history, the main timeline. A draft is a temporary timeline that branches from a parent, main or another active draft: writes go to the draft, reads of the draft see them, and nothing outside the draft does. The draft is later promoted (all of it becomes part of its parent at once) or discarded.

Typical uses:

  • the pending day of a game: promote when the player sleeps, discard when the player quits without saving;
  • a scene or session generated by a model, waiting for the author’s approval;
  • a chapter in progress that should not yet count as canon;
  • a scene under review inside the pending day, or a chapter inside an arc: a draft of a draft.

Drafts nest at most 4 deep below main (MAX_DRAFT_DEPTH): a draft of main has depth 1. Merging two drafts is not part of v1.

Model

JSON
{
  "projectId": "p",
  "id": "day-12",
  "kind": "draft",
  "status": "active",
  "parentTimelineId": "main",
  "depth": 1,
  "baseVersion": 41,
  "headVersion": 57,
  "maxTick": 12,
  "label": "Day 12",
  "createdAt": "2026-10-07T12:00:00Z"
}
- `kind` is `main` or `draft`. Main always exists and is always `active`. - `status` is `active`, `promoted` or `discarded`. Only an active draft accepts writes. - `parentTimelineId` is the timeline the draft branched from (`main` or a draft); main has none. `depth` counts the drafts from main down to this timeline: 0 for main, 1 for a draft of main. - `baseVersion` is the parent's head when the draft was opened. - `headVersion` is the version of the timeline's latest commit (the base, for a draft with no commits). - `closedAt` is set by promote and discard; `purgedAt` by purge.

Versions

Every commit receives a version from a single counter per project, whichever timeline it writes to. Versions are therefore unique and increasing across timelines, and a record keeps its recordedAt when its draft is promoted.

  • Project.version is main’s head version. When drafts exist, main’s versions are not consecutive: the numbers taken by draft commits are skipped.
  • A promote into a draft moves the draft’s head into the parent’s timeline row; only a promote into main changes Project.version.
  • expectedVersion on a commit is compared with the head of the commit’s target timeline.
  • At.asOfVersion cannot exceed the head of the timeline being read. Missing At values resolve to that timeline’s head version and highest tick.

Visibility

One rule applies to every read: records, transitions, events, claims, awareness, dispositions, acquisitions and full-text search.

  • Reading main sees main rows only.
  • Reading draft D with ancestors D → P1 → P2 → … → main sees D’s rows, plus the rows of each ancestor recorded up to the baseVersion of its child on that chain: P1 up to D’s base, P2 up to P1’s base, …, main up to the base of the draft opened from it. Commits made to an ancestor after the branch below it was opened are invisible to D.

Each base is at least the one above it, so D sees exactly what its chain held when each of its drafts was opened.

A transition written in a draft may target a record of an ancestor. It changes that record’s status only in reads of the draft (and of drafts opened from it later), until the draft is promoted.

Validation of a commit uses the same rule: a draft commit may reference records and events of the draft itself and of its ancestors as the draft sees them; a commit may never reference rows of a timeline outside its chain.

Operations

OperationPayloadResult
timeline.openDraft{ projectId, id?, label?, baseTimelineId? } (main by default, or an active draft)Timeline
timeline.promote{ projectId, timelineId }Timeline
timeline.rebase{ projectId, timelineId }Timeline
timeline.discard{ projectId, timelineId, purge? }Timeline
timeline.purge{ projectId, timelineId }{ purged, removed }
timeline.get{ projectId, timelineId }Timeline
timeline.list{ projectId, status? }Timeline[]

Every write operation (commit, commitBatch, propose, transmit, transmitBatch, witness and the batch maintenance operations) accepts timelineId, defaulting to main. A proposal applies to the timeline it was proposed for. All commits of one batch must target the same timeline, otherwise the batch fails with VALIDATION_FAILED.

Every read operation (holds, holdings, whoHolds, canon, perspective, compare, contradictions, trace, timeline, record, search) accepts timelineId, defaulting to main. Reading an active or discarded draft applies the visibility rule above. Reading a promoted draft fails with INVALID_STATE and details: { promotedInto: "main", headVersion }; read main instead. Reading a purged or unknown draft fails with NOT_FOUND.

Every read operation also reads a draft of a draft through the visibility rule above. Reading a promoted draft names the timeline it was promoted into.

changes is a global feed: without timelineId it returns every timeline’s items in commit order, each with its timelineId. With timelineId it returns only that timeline’s items. When a draft is promoted, its items move to its parent and a timeline.promoted item { timelineId, into, baseVersion, headVersion, reparented } is appended to the parent’s feed; a client that follows only the parent learns there that the draft’s changes now belong to it, and which drafts were handed to it (reparented, see Promote).

Open

timeline.openDraft records the parent’s current head as baseVersion. baseTimelineId names the parent: main (the default) or a draft. Without an id, the engine generates one. main is not a valid draft id and an existing timeline id returns CONFLICT. An unknown or purged parent returns NOT_FOUND; a promoted or discarded one returns INVALID_STATE. Opening a draft below a draft of depth 4 returns VALIDATION_FAILED with details: { parentTimelineId, maxDepth: 4 }.

Promote

Promote runs in one write transaction. It requires an active draft, an active parent, and the parent’s head to still equal the draft’s baseVersion; otherwise it fails with VERSION_CONFLICT and details: { baseVersion, parentTimelineId, parentHeadVersion } (plus mainHeadVersion when the parent is main), and the draft stays active and unchanged. The client then decides: rebase the draft, discard it, or open a new one and replay its work. This is the same contract as expectedVersion.

On success every row of the draft moves to its parent, keeping its version; the parent’s head becomes the draft’s head and its highest tick becomes the larger of the two. No earlier read of the parent changes: it never had those versions before. Promoting into a draft confirms nothing in main.

The draft’s children (every draft opened from it that was neither promoted nor purged) are handed to the parent and keep their baseVersion. What each child sees does not change: the promote required the parent’s head to equal the draft’s base, so every parent row is at or below it, and the draft’s rows a child saw are now parent rows below the same bound. Parent commits made later get higher versions and stay invisible to the child.

Rebase

timeline.rebase replays an active draft on top of its parent’s current head, in one write transaction:

  1. the draft’s commits are read back in order, with their ids, attempt ids, fingerprints and preconditions (a transmit checks again that its source is still active);
  2. the draft’s rows are removed and its base becomes the parent’s head;
  3. every commit is validated and applied again, receiving new versions. A commit whose attempt an ancestor already applied (same request) is not applied twice. Events keep their positions on additional axes, and documents keep their vectors (the ids do not change).

A draft that other drafts depend on (a descendant neither promoted nor purged) cannot be rebased: its rows would get new versions under its children’s bases. The rebase fails with INVALID_STATE and details: { timelineId, descendants }.

If every commit still fits, the draft can be promoted, and a timeline.rebased change item records the move. If one no longer fits, nothing changes: the error keeps the validation code and adds details: { timelineId, failedAttemptId, failedEventId, cause }. A draft already at main’s head is returned unchanged.

Rebase resolves no conflict of meaning: if the parent says the castle fell and the draft has someone believing it stands, both are kept and contradictions reports them.

Discard

timeline.discard archives an active draft: it accepts no more writes, stays readable by its id, and stays invisible to every other read. With purge: true, it is also purged in the same call. A draft with an active descendant cannot be discarded (INVALID_STATE, details.descendants): nothing is discarded that the client did not name.

Purge

timeline.purge physically removes every row of a discarded draft (events and their positions on additional axes, records, transitions, indexes, full-text entries, vectors, attempts and change-feed items) and keeps the timeline row with purgedAt. Purging main or a promoted draft fails with INVALID_STATE, and so does purging a draft that a discarded but unpurged descendant still reads (details.descendants). Purging an already purged draft is a no-op that reports zero removed rows, so a client can retry it safely.

Confirmed history is never deleted: only a draft that was never promoted can be purged. Rows promoted into a draft are not confirmed; they are purged with that draft.

Ids and attempts

A rebase keeps every id and attempt of the draft; only versions change.

Event, record and transition ids are unique per project across all timelines. The ids of a discarded draft stay reserved until it is purged: reusing one in another draft or in main fails with CONFLICT. A client that replays the same work with the same ids, such as a game reloading the same day, must discard with purge: true.

An attempt is replayed where the commit can see it: a commit to main looks for its attemptId in main; a commit to a draft looks in the draft and in its ancestors as the draft sees them (the draft’s own attempt first). Attempts of other drafts are never considered. The attempt fingerprint ignores timelineId, so the same request is recognized in any timeline that can see its earlier attempt. Promote moves a draft’s attempts to its parent; purge removes them, so their attemptIds can be used again.

Transmit events receive engine-generated ids, because a transmit carries no event id; a replayed day therefore has new ids for those events.

Performance

Reads of main at its current head use the materialized status table, as before. Reads of a draft always take the historical path: they check transitions directly (NOT EXISTS over the transition index) instead of the materialized status. Drafts are short-lived and small, so this costs little and keeps a single source of truth for statuses.

Visibility is a residual filter: no claim index contains timeline_id, so a draft read uses exactly the same index, and reads rows in the same order, as the equivalent main read, at any depth. The filter has a fixed shape: the timeline read plus four ancestor slots, unused slots bound to NULL. Event pages of a draft read one ordered range of the events index per slot (the draft’s and each ancestor’s) and merge at most five pages.

Promote and purge reach a draft’s rows through indexes (timeline indexes on events and records, primary keys on attempts and statuses, and partial indexes that contain only draft rows for claims, transitions and the change feed), so their cost follows the size of the draft, not of the history. The one exception is the full-text index: FTS5 cannot index the columns that identify a draft’s entries, so purge scans search_fts.

Was this page helpful?