The document on your screen tells you what a team currently agrees on. It rarely tells you how that agreement formed: which wording lost, who moved the date, or what the board actually approved last month. Most teams answer those questions with filenames. proposal_v3_final_SK.docx preserves a moment, and leaves the comparison and the attribution to whoever opens it next.
Dynodoc is the document workspace Verne builds on the opposite assumption. The current text is one view of a longer record, and the record is part of the product. Every paragraph and spreadsheet row keeps a permanent identity, people think in personal drafts, overlapping edits are resolved on purpose, and restoring old text adds to the history instead of erasing it. This note explains why we treat those as product features rather than housekeeping, what Dynodoc implements today, and where its limits are.
A file is only the current answer
A saved file is a state. It holds the words but not the operations that produced them, so every question about change has to be reconstructed later, and reconstruction is harder than it looks. Wikipedia keeps every revision of every article, yet attributing each word to the edit that introduced it took dedicated research. The WikiWho authors noted that MediaWiki offered no direct way to show who wrote a given piece of text, and their algorithm reported an average of 95% precision on a hand-annotated gold standard of 240 tokens[1]. That is a strong result for a hard inference problem. It is also a reminder that authorship becomes an inference problem as soon as you keep only snapshots.
Software engineers solved a version of this long ago, but their tools assume code. The Ink & Switch essay on local-first software credits Git with excellent asynchronous collaboration, then notes that it is optimised for line-based text and treats other formats as binary blobs that cannot meaningfully be edited or merged[2]. Older version-control systems also made the line the unit of conflict: two people who touch the same line collide, whatever they changed, as Ignat and Norrie observed when they proposed conflict rules at the level of paragraphs, sentences and words[3]. In a word processor, a whole paragraph is often a single line.
Dynodoc records the operations themselves. Each content change is a typed event such as NodeCreated, FieldEdited, NodeMoved or NodeRestored, attributed to the person who made it and linked into a hash chain. The current document is what you get by replaying those events; snapshots exist to make reading fast and are always derived from the log, never the reverse[4]. The figure walks one paragraph through that record.
01J8ZB2M4QDelivery
Deliver the complete project by the end of August.
Compared with empty document.
Team history
- #1NodeCreatedMaya
Step 1 of 6. Maya writes the delivery paragraph in the team version. The engine records event 1 and gives the block an ID it keeps for life.
Two details carry most of the weight. A named version is a pointer: saving “Board review” pins a snapshot to a position in the log, so keeping many of them is cheap and none of them can drift. And restoration moves forward. Dynodoc compares the saved snapshot with the current state, turns the difference into ordinary operations and appends them, after checking that nobody changed the document in the meantime.
// A restore is refused if the document moved on since the requester looked.
if seq != req.base_seq {
return Err(ApiError::Conflict {
reason: "The document changed. Review the latest version before restoring.".into(),
});
}
// The saved snapshot becomes ordinary operations against the current state...
let ops = engine_core::workspace_merge::diff(¤t, &target);
for op in &ops {
governance::authorize(role, op, ¤t)?;
let op = &engine_core::richtext::compact_event(¤t, op);
apply::materialize_node_change(&mut tx, doc, op).await?;
// ...and each one is appended to the hash-chained log, attributed to the actor.
events.push(log::append_in_tx(&mut tx, doc, op, actor).await?);
apply_payload(&mut current, op)?;
}
tx.commit().await?;After the restore, Rafi’s edit is still in the record. If someone later asks why October was ever on the table, the answer is there, with his name on it.
Drafts are where the thinking happens
Real-time co-editing made shared documents normal, and it also made drafting public. When Ink & Switch interviewed professional writers for Upwelling, a prototype that combines real-time collaboration with version control, they described a “fishbowl effect”: people uncomfortable being watched mid-sentence, and a journalist who put it plainly, “Writers don’t want first drafts visible to the editor.” Upwelling’s answer was the draft, which its authors compare to a commit or a branch in Git: a place to group related changes before they are merged into the shared document[5].
The same group’s Peritext paper names the architectural reason most editors lack this. Google Docs and similar systems assume a document has a single, linear timeline of edits managed by a cloud service, which leaves no room for versions that exist side by side and merge when their authors are ready[6].
In Dynodoc, anyone with access can open a personal draft beside the team version. Both live on the server, so a draft is private rather than offline. When the writer is ready, Get latest into my draft brings in the team’s newer changes and Share included changes sends selected blocks back, provided the writer has editing rights. Blocks left unchecked can stay private in future comparisons, so an unfinished section can wait without holding up the finished ones.
We also chose not to teach Git. Perez De Rosso and Jackson’s conceptual analysis found that Git puzzles even experienced developers, and traced much of the difficulty to concepts such as the staging area and the tracked file, which have no meaning outside the tool[7]. Dynodoc keeps the mechanics and names them after work people already do. These are mappings for a document editor, not an implementation of Git’s protocol.
- branch
- Personal draftYour own workspace beside the team version
- pull
- Get latestBring the team’s newer changes into your draft
- stage, push
- Include in sharing, Share changesSend selected blocks to the team version
- commit
- Save versionA named, immutable snapshot
- merge conflict
- Review overlapsChoose what belongs when both sides changed one thing
- revert
- Restore as new changesEarlier records stay intact
- blame
- Who changed this?Attribution for a block, visible to members
Identity makes a change explainable
To compare two versions, software first has to decide which parts correspond. Khanna, Kunal and Pierce’s formal study of diff3, the classic three-way merge tool, frames this as alignment. Where data is rigidly structured or has keys, the right alignment is generally clear; for text documents it is much less clear how to choose alignments that people would consider natural[8]. A paragraph that moves and gains a word looks, to a text comparison, like one paragraph deleted and another written.
Block-based tools have converged on the same remedy: give each unit an identifier that outlives its content. Notion gives every block a random UUID and keeps its properties when the block changes type[9]. Jupyter’s cell ID proposal standardised identifiers so that cells can be linked, annotated in code review and compared across runs, and once created an ID stays the same[10]. Automerge carries the idea down to single characters, which is what lets it compute an exact difference between two points in a document’s history instead of an approximate one[11].
In Dynodoc, each paragraph, question, choice and spreadsheet row receives a ULID when it is created. The engine’s own comment on the type is blunt: the ID never changes for a node’s lifetime and is never reused. Spreadsheet cells are fields of those stable row nodes, so row identities survive moves. Deleting a block leaves a tombstone, and restoring it brings back the same identity rather than a lookalike.
- 01J8ZB2M4QTrack every change to the intake form.
- 01J8ZB2M7TReviewers comment before a version is saved.
- 01J8ZB2MA1Named versions mark what the board approved.
- 01J8ZB2MC9Restoring old text adds a new change.
Matched by position
Compares paragraph 1 with paragraph 1, and so on.
No changes yet.
Matched by stable ID
The events Dynodoc would append, in order.
No changes yet.
Moving one paragraph looks like three rewrites to a positional comparison. With stable IDs it is one NodeMoved, and a restored paragraph comes back as itself.
Every event names the node it touches, and that one field is what lets history, comparison and merging talk about the same object. Dynodoc’s web client types the vocabulary like this, mirroring the Rust definitions in the engine:
/** One node in the materialized current state (`MaterializedNode`). */
export interface MaterializedNode {
id: string; // ULID: assigned at creation, never changed, never reused
parent_id: string | null;
type: NodeType;
pos: string; // dense order key among siblings
current_fields: Record<string, unknown>;
var_name: string | null;
deleted: boolean; // a tombstone; nodes are never row-deleted
}
/** The internally-tagged event payload (`engine_shared::EventPayload`). */
export type EventPayload =
| { type: 'NodeCreated'; node_id: string; node_type: NodeType;
parent_id?: string | null; pos: string; fields?: unknown }
| { type: 'NodeMoved'; node_id: string; new_parent_id?: string | null; new_pos: string }
| { type: 'NodeDeleted'; node_id: string }
| { type: 'NodeRestored'; node_id: string }
| { type: 'FieldEdited'; node_id: string; field: string; value: unknown }
| { type: 'RichTextPatched'; node_id: string; patch: RichTextPatch }
// ...plus comment, suggestion and deployment eventsIdentity has limits worth stating. Formatting runs and character offsets are not identities in Dynodoc: bolding one word splits a run, and an offset only means something relative to a checked base revision. Paragraphs nested inside tables and lists do not yet have permanent IDs of their own. And a stable ID does not make structure independent. Moving or deleting a block can affect its parent, its neighbours and anything that refers to it, which is why merged structural changes still pass validation before they land.
A merge should make uncertainty visible
A three-way merge compares three states: the common ancestor, the current team version and a personal draft. A change only one side made can be taken from that side. The hard cases are the ones both sides touched, and they are harder than intuition suggests. The diff3 study showed that the obvious formulations of “edits to well-separated regions never conflict” are wrong in general, and hold only under a precise separation condition[8].
Real-time collaboration libraries take a different route. Automerge merges concurrent changes automatically so that everybody ends up in the same state and no changes are lost[12], and the local-first authors found that users of their prototypes rarely met conflicts and that generic resolution mechanisms worked well[2]. For live typing that is often the right trade. But converging is not the same as preserving what people meant. Kleppmann and colleagues showed that several published collaborative-editing algorithms can interleave two concurrent insertions at the same position character by character, leaving an unreadable jumble[13], and Peritext exists because concurrent formatting at the edge of a span has no single obvious answer[6].
Dynodoc is built for documents that someone will sign off, so it settles quietly only what it can settle safely and asks about the rest. Edits to different blocks, cells or fields combine. Inside a paragraph, heading or code block, edits to separate ranges of text combine, and independent formatting such as bold on one side and italic on the other merges property by property. Different values for the same words or property, a deletion on one side against an edit on the other, and two insertions at the same position all stop for review.
01J8ZC1K2AScope
The pilot covers the intake form and the weekly review.
The pilot covers the intake form and, the weekly review and the monthly report.
The pilot covers the intake form and the weekly review.
Only the team changed this paragraph. The team’s text stays.
01J8ZC1K8HOwnership
Maya owns delivery and Sam owns the budget.
Maya owns delivery and Sam owns the budget.
Maya owns delivery and Sam Rafi owns the budget.
Only your draft changed this paragraph. Your text is taken.
Result if you share now
Changes are marked against the text before either edit.
- 01J8ZC1K2AThe pilot covers the intake form
and, the weekly review and the monthly report. - 01J8ZC1K5DDeliver the first phase in September after review.
- 01J8ZC1K8HMaya owns delivery and
SamRafi owns the budget.
No overlaps. Sharing needs no questions.
The review asks in plain terms. Dynodoc shows the text as it was before either edit, offers Keep the team’s version or Keep my draft for each overlap, and will not share until every overlap has an answer. Ignat and Norrie argued for this kind of choice between conflicting units, at a granularity people recognise, over conflicts defined by lines[3]. The rule underneath is short. This is the per-field decision at the centre of the engine’s three-way merge:
fn select(
id: &NodeId,
field: &str,
base: Value,
team: Value,
draft: Value,
resolutions: &BTreeMap<String, String>,
conflicts: &mut Vec<Conflict>,
) -> Value {
if draft == base || draft == team {
return team;
}
if team == base {
return draft;
}
if field == "content" {
if let Some(merged) = crate::richtext::merge(&base, &team, &draft) {
return merged;
}
}
let key = format!("{}:{field}", id.0);
match resolutions.get(&key).map(String::as_str) {
Some("draft") => draft,
Some("team") => team,
_ => {
conflicts.push(Conflict {
key,
node_id: id.clone(),
field: field.into(),
ancestor: base,
team: team.clone(),
draft,
});
team
}
}
}If only one side changed a field, that side wins. If both made the same change, there is nothing to decide. Rich-text content gets one more chance through the paragraph-level merge. Anything left becomes a Conflict carrying all three values, and the team’s value stays in place until a person picks. The code never guesses on the reader’s behalf.
What Dynodoc does today
Dynodoc is a hosted workspace under active development. As of 25 September 2026, its working paths include:
- Document and spreadsheet editors with common formatting, threaded comments that can be resolved and reopened, and tracked text suggestions that authors accept or reject.
- Personal drafts, selected-block sharing, Get latest, overlap review, named versions, comparison, attribution through Who changed this?, and restoration as new changes.
- Roles for organisations, teams and folders, from owner to viewer, that protect reads, writes, live updates and search.
- Read-only public copies: a frozen capture of the team version behind a revocable link, without comments, suggestions or history.
- Import of DOCX, DOC, XLSX and CSV files with the original kept for reference, plus snapshot imports from research tools such as ODK Central and KoboToolbox.
Underneath, the architecture is centralised semantic version control. One PostgreSQL database is the authority, writes to each document are serialised, and events are append-only and hash-linked. The core is published separately as Dynodoc Engine, an early 0.1 source release in Rust with the event log, replay, snapshots, stable IDs, rich-text patches and three-way merge, under the MIT licence[4]. The Dynodoc application around it keeps its own licence, the GNU AGPL[14].
What the history does not prove
A hash chain makes tampering evident relative to something you already hold. Crosby and Wallach’s work on tamper-evident logs puts it sharply: without further mechanisms, an untrusted logger can present snapshots that make inconsistent claims about the past, and tampering is only detected if someone audits[15]. Dynodoc can verify that a document’s recorded chain is internally consistent. On its own it cannot detect a privileged database operator who rewrites an entire chain and its head, and the current deployment does not publish external checkpoints. Nor does a verified chain show that any statement in the document is true.
The other limits concern scope.
- No offline editing or CRDT layer. There are no disconnected replicas or vector clocks. Optional browser recovery keeps unsaved work on a trusted device, but it is recovery, not a synchronisation protocol. Offline collaboration is a planned priority with no release date[14].
- No live co-typing. Dynodoc does not offer character-by-character co-editing or live cursors. Collaboration runs through drafts and deliberate sharing.
- No full Office parity. Common editing works. Exact Word and Excel round trips and many advanced features remain open, so conversion warnings deserve a careful read.
- Merging is scoped. The paragraph merge is a set of conservative rules, not a proof that arbitrary concurrent rich-text edits converge or keep their intent. Edits in a personal draft do not carry the canonical chain’s cryptographic guarantee until they are shared.
- Not a consultation platform. Large-scale public consultation is a design for a later extension. None of it is implemented.
How to evaluate it
A generic demo proves little about your documents. A useful pilot takes a representative file and a real review sequence. Import it and read the conversion warnings. Make independent edits in two personal drafts, create a deliberate overlap and resolve it. Save a named version, restore an earlier passage, then ask Who changed this? about a paragraph you care about. If your team depends on particular spreadsheet formulas or questionnaire logic, test those directly: Dynodoc supports documented subsets, not every vendor’s rules. The workspace and its compatibility notes are at dynodoc.online (opens in a new tab)[14], and the engine’s source, guarantees and limits are on GitHub (opens in a new tab)[4].
We built Dynodoc because the questions teams ask about documents are usually questions about history. Who changed this? What did we approve? What happens if we take this draft? A file cannot answer them. A record designed for the purpose can.
Sources
- WikiWho: Precise and Efficient Attribution of Authorship of Revisioned Content Word-level authorship has to be inferred when only whole revisions are stored; 95% average precision on a 240-token gold standard.
- Local-first software: You own your data, in spite of the cloud Git treats non-line-based formats as binary blobs; users of CRDT prototypes rarely met conflicts.
- Flexible Definition and Resolution of Conflicts through Multi-level Editing Line-based merging treats any two edits to a line as a conflict; proposes paragraph, sentence and word granularity with user choice.
- Dynodoc Engine: source, guarantees and limits The event vocabulary, stable IDs, three-way merge and restore code quoted here; states that no CRDT, offline replica or external anchoring is supplied.
- Upwelling: Combining real-time collaboration with version control for writers The fishbowl effect; drafts as private, mergeable layers with full attribution.
- Peritext: A CRDT for Rich-Text Collaboration Single linear timelines versus side-by-side versions; formatting intent at span boundaries.
- What's Wrong with Git? A Conceptual Design Analysis Git puzzles even experienced developers; concepts such as the staging area have no meaning outside the tool.
- A Formal Investigation of Diff3 Alignment is clear when data has keys and unclear for text; natural intuitions about separated edits fail in general.
- The data model behind Notion's flexibility Every block has a UUID; changing a block's type keeps its properties.
- Cell ID Addition to Notebook Format (JEP 62) Cell IDs for links, review comments and comparisons; an ID stays the same once created.
- History and diffs with Automerge Per-character IDs let Automerge compute exact differences between points in history.
- Welcome to Automerge Concurrent changes merge automatically so everybody ends up in the same state.
- Interleaving anomalies in collaborative text editors Several published algorithms can interleave concurrent insertions character by character.
- Dynodoc: version control for documents Product scope, AGPL application licence, and stated limits such as no live co-editing and no dated offline release.
- Efficient Data Structures for Tamper-Evident Logging An untrusted logger can make inconsistent claims about the past; tamper evidence requires auditing.