Overview
An agent does not write the project. It appends typed Ops to a branch, a
journal keyed to the content hash of the state it forked from, and a human
applies or discards it.
Three things forced this, all of them behind the ordinary
patch_render_state path and none of them about transport:
project::writer::update_project_editsrewrites the whole.recastzip per call, raw-copyingrecording.mp4. An agent paid that per verb: fifty edits on a 600 MB project is roughly 30 GB of copying.try_acquire_write_lockhad no same-writer check, so an agent's second edit inside the 60s TTL failed witheditor_lockednaming itself as the holder. Every multi-step agent edit was broken untilclassify_claim(commands/editor_session.rs) landed.- Undo lived only in the frontend store. Nothing outside the GUI could take an edit back.
A branch fixes all three: it never touches the bundle, it never takes the write
lock, and truncate_after is undo.
Rejected, deliberately: CRDTs and multi-writer merge (one human decides),
splitting media out of the .recast (the bundle is the unit users move around),
and a per-project lock map (agents never take the lock now, so the single slot
costs nothing).
Diagram
flowchart LR
agent["Agent<br/>(MCP client)"] -->|"branch.append"| svc
cli["recast branch …"] --> svc
gui["Review panel<br/>(editor GUI)"] --> svc
svc["BranchService<br/>commands/branches.rs"] --> journal[("<app_data>/branches/<key>/<id>.json")]
svc --> ops["apply_op<br/>render/ops.rs"]
journal -->|"replay onto base"| materialized["materialize → RenderState"]
materialized --> diff["journal::diff → Vec<FieldChange>"]
diff --> gui
gui -->|"human approves"| apply["apply → patch_render_state"]
apply --> project[(".recast bundle")]sequenceDiagram
participant A as Agent
participant S as BranchService
participant J as Journal
participant H as Human
A->>S: branch.create(project, author, label)
S->>J: Branch::new(id, StateHash::of(state))
A->>S: branch.append(ops, expectSeq, idemKey)
S->>S: apply_ops on a clone, now
Note over S: a bad op fails here, not at apply
S->>J: Entry { seq, idem_key, ops, at_ms }
H->>S: branch.diff(id)
S-->>H: Vec<FieldChange>
H->>S: branch.apply(id, writerId)
S->>S: materialize, rejects on BaseMoved
S->>J: fold into the bundle, then remove the journalKey components
| Component | File | Responsibility |
|---|---|---|
Op | render/ops.rs | 16 variants: trim, cuts, zoom, split points, speed, annotations, scene anims, generic Set, whole-state Replace |
apply_op | render/ops.rs | (&mut RenderState, &Op) -> Result<Value, OpError>; pure, no clock, no IO |
apply_ops | render/ops.rs | All-or-nothing batch over a clone; a mid-batch failure leaves the branch untouched |
OpError | render/ops.rs | thiserror; index-out-of-range, selector-missing, not-found, FieldTypeMismatch |
StateHash | project/journal.rs | [u8; 32] sha256 of the serialized RenderState, hex in JSON via hex_bytes |
BranchId | project/journal.rs | Client-chosen name, validated because it is also the journal's file stem |
Entry / Branch | project/journal.rs | {seq, idem_key, ops, at_ms} on one base: StateHash |
Branch::append | project/journal.rs | expect_seq check, idem-key replay, returns Append::Recorded or AlreadyApplied |
Branch::materialize | project/journal.rs | Replays entries onto the base; JournalError::BaseMoved if the hash shifted |
Branch::compact | project/journal.rs | Past COMPACT_AFTER_ENTRIES (512) collapses to one Op::Replace, keeping the base |
Branch::truncate_after | project/journal.rs | Server-side undo: drop every entry past seq |
BranchStore | project/journal.rs | One directory of <id>.json; list skips unparseable files so one corrupt journal cannot hide the rest |
project_key | project/journal.rs | Maps a .recast path to its journal directory name |
BranchService | commands/branches.rs | The shared layer: 8 methods, called by socket dispatch, Tauri commands, and MCP |
BranchService::apply | commands/branches.rs | Materializes inside patch_render_state's closure, so the fold is one atomic bundle write |
Server::handle | mcp/protocol.rs | Pure (&Value, &impl ToolHost) -> Option<Value>; testable with no socket and no process |
TOOLS | mcp/tools.rs | 11 tool descriptors, each a closed JSON Schema: one to discover projects, the rest read-only or branch-scoped |
resources/* | mcp/protocol.rs | Each project as a recast://project/<encoded path> resource, so a client can attach state without spending a tool call |
Control / data flow
Op is a wire contract, not an internal enum:
#[serde(tag = "op", rename_all = "camelCase", rename_all_fields = "camelCase")]
pub enum Op { /* … */ }
Those names are serialized into journals on disk. Renaming a variant or a field invalidates every journal that exists.
Append validates twice, immediately. BranchService::append replays the
incoming ops onto a materialized clone, then runs validate_render_state over
the result, before writing the entry. The first catches an op that cannot apply:
Err(JournalError::Replay { branch, seq, source: OpError::CutIndexOutOfRange { .. } })
The second catches an op that applies cleanly and still produces nonsense, such
as a trim past the end of the source. Both return before store.save, so a
rejected append leaves the journal on disk untouched and the agent can correct
the op and retry. Without the second check the failure surfaced at apply time,
in front of the reviewer, who could do nothing about it.
A retried idem_key skips validation. It proposes nothing new, so re-judging it
would let a project edited out of band turn a settled no-op into a failure.
Concurrency is optimistic, retries are idempotent. expect_seq rejects a
stale writer with SeqMismatch { expected, actual }; an idem_key already on
the branch returns Append::AlreadyApplied { seq } instead of duplicating the
edit, so a network retry is free.
Apply is fast-forward only. materialize recomputes StateHash::of(current)
and refuses if it moved:
branch forked from 9f2c… but the project is now at 41ab…
That catches a GUI save landing between fork and apply, and a bundle edited out of band. On success the journal is deleted: a branch is consumed, not archived.
Invariants & gotchas
apply_opmust stay pure. NoSystemTime, no randomness, no filesystem. Journals are replayed to rebuild state, so an id minted from the clock at edit time diverges on replay. Theannotations.addfallback id and the zoom defaults are resolved at the dispatch edge and baked into the op.- Compaction keeps the fork point. The first design moved the base forward,
which would make
materializereject the exact project state the branch applies to. It collapses into oneOp::Replaceon the original base instead. - There is no revision counter.
StateHashsubsumes one, catches out-of-band edits, and needs no project-format migration. Per-branchseqsupplies the ordering a counter would have. - Journals live under the app data dir, not beside the
.recast. Pending human review is not temporary work and must not be reclaimed by the temp-dir sweeper. - Sweep only discards provably worthless branches: empty (created, never
appended) and older than
EMPTY_BRANCH_MAX_AGE_MS(24h). A branch carrying ops is never auto-deleted; pastSTALE_AFTER_MS(7d) it is flaggedstaleand the reviewer decides. Unreadable journals are left alone, because we cannot tell whether they hold work. It runs fromlist, the one call every surface makes, rather than a background timer. - Discovery is the entry point.
recast_project_listis the only tool that takes no arguments, and every other project tool needs a path. Without it an agent could work only on a path a human pasted, which made the whole surface unreachable on its own.a_project_path_is_discoverable_without_already_having_one(mcp/tools.rs) pins that there is always such a way in. createrefuses rather than overwrites. Reusing the id of a branch that holds ops returnsBranchExists; reusing one that holds none re-forks it, so an agent that crashed between create and its first append can simply retry. The unguardedBranchStore::savestill exists for writing back a branch that was loaded, but forking goes throughBranchStore::create.- The branch cap bounds the reviewer, not the disk. Journals are KB-scale.
MAX_BRANCHES_PER_PROJECT(32) exists so a looping agent cannot bury a human under a review list, and it sweeps abandoned empty branches before it counts, so the cap measures live work. - Listing resources never fails. A client calls
resources/liston connect; an unreachable app answers with an empty library rather than an error, which would read as a broken server. - No MCP tool writes.
branch.apply, theeditor.*mutators,rec.*andexport.*are absent fromTOOLS, andno_tool_writes_the_project_directly(mcp/tools.rs) asserts it. Failing verbs returnisError: truewith the message intact so the model can readeditor_locked: …and back off. rmcpis not used. Its current release needs rustc 1.88 while this crate pinsrust-version = "1.82.0", so cargo silently resolves to 2.2.0 rather than failing. The protocol is ~200 lines; the silent downgrade is not worth it. Revisit if the MSRV moves for another reason.- Proposing is free.
proposing_edits_leaves_the_bundle_untouchedbyte-compares the.recastbefore and after an append.
Related
- State and the project format: what a branch forks from and folds back into.
- CLI and the control socket: the transport the branch verbs and MCP share.
- IPC and the Tauri boundary: how the GUI reaches the same service.