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
{
"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"
}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.versionis 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. expectedVersionon a commit is compared with the head of the commit’s target timeline.At.asOfVersioncannot exceed the head of the timeline being read. MissingAtvalues 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 → … → mainsees D’s rows, plus the rows of each ancestor recorded up to thebaseVersionof 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
| Operation | Payload | Result |
|---|---|---|
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:
- 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);
- the draft’s rows are removed and its base becomes the parent’s head;
- 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.