Skip to content
Documentation GitHub
Data Flow

Write Path

Status: Accepted Reference epics: INK-826, INK-827, INK-828, INK-829, INK-830 ADRs: ADR-017, ADR-018, ADR-019

How a write — whether the user typed it, the agent produced it, a scheduled task emitted it, or an import pipeline delivered it — becomes a persisted change to the workspace. Every write is expressed as a WorldWrite and crosses the submit boundary. There is no back door.


Every write that modifies workspace-visible content is expressed as a WorldWrite value constructed in the domain. The domain cannot be modified by any other means. See ADR-017 and domain rule 1.

This is a domain invariant, not a convention. It is expressed in the type system: the repositories that persist workspace content accept WorldWrite values, not loose arguments. A caller who wants to modify workspace content cannot skip the boundary because there is no function to call that skips it.

The boundary is agnostic to caller identity. The same invariant governs:

CallerOrigin carriedLifecycle default
Editor (user typing)AuthoredCanonical (or as chosen by the author)
Agent tool callAgentProducedCandidate (submit-boundary §agent-writes-default-to-candidate)
Scheduled autonomous taskAgentProducedCandidate
Import pipelineImportedDraft
Sandboxed CPython executor toolPer caller (usually AgentProduced)Per caller (usually Candidate)

Origin and lifecycle defaults are explained in systems/world/provenance. The rest of this page walks the runtime path.


Three framings to carry through:

  1. One construct, one validator. Every caller — a Tauri command from the editor, an MCP tool call from the sidecar, an import step — ends up at the same WorldWrite construction site and the same validator. Malformed writes cannot be applied because the type system refuses them.
  2. Side effects are derivative of the write, not preconditions on it. The WriteEffectCoordinator fires the event log, embedding queue, re-validation flagger, and sync queue after the write commits. Side-effect failures do not roll back the write.
  3. Deviation on conflict, never silent override. When a submission conflicts with existing canonical content, a DeviationRecord is produced alongside (or instead of) applying the write per domain rule 5. Capability denials are not deviations (domain rule 7).

Callers construct a WorldWrite through a typed adapter that enforces the caller’s identity. Adapters populate the fields the domain requires:

FieldSourceNotes
originCaller identityEditor adapter populates Authored; MCP adapter populates AgentProduced; import adapter populates Imported. Callers cannot pass arbitrary origin values.
lifecycleCall contextEditor writes inherit the author’s chosen lifecycle (usually Canonical); agent writes default to Candidate; import writes land as Draft.
origin_source_idCallerTool id for agent writes; import-run id for import writes; editor session id for author writes.
derivation_sourcesDeclaredTools that are derivation-emitting declare their source arguments at registration. Adapter pulls the source page ids from the call.
contentCallerThe actual content payload (page create, block update, tag assignment, etc.).
capability_intentCallerThe capability the caller claims to be exercising, checked by the boundary.

The existing author-typed-into-editor path is one instance of this pattern:

Code: apps/desktop/src-tauri/src/commands/page.rs, crates/application/src/page/save_block_content.rs

The editor’s save_block_content Tauri command resolves the active workspace and constructs a WorldWrite carrying origin: Authored, the LoroDoc blob, the materialized text, and the capability intent. The use case — SaveBlockContentUseCase — hands the WorldWrite to the domain validator and then to the repository. The editor’s write is not special; it just uses a different adapter than the agent’s MCP-bridged tool does.

For editor writes specifically, the content payload originates in a LoroDoc CRDT instance kept in lockstep with the TipTap editor:

  • LoroSyncPlugin intercepts every ProseMirror transaction and applies it to the per-block LoroDoc.
  • LoroUndoPlugin replaces ProseMirror’s built-in undo with Loro-backed undo so the full operation history survives across sessions.
  • On save, the React handler exports a full CRDT snapshot (doc.export({ mode: 'snapshot' })) as the blob and doc.getText(...) as the materialized text.
  • The blob is the source of truth; the text is a derived projection used only for search indexing. The blob is always passed through unchanged from the frontend to storage — it is never re-serialized from the text. See the CRDT BLOB passthrough invariant in the data-flow overview.

For agent writes, the call originates in the Python sidecar and reaches Rust over MCP. The MCP adapter layer is where the WorldWrite is constructed on the agent’s behalf; see agent-session §stage-3-tool-execution-and-the-submit-boundary for the sidecar-side view. Python-native tools that need to write call a Rust tool over MCP; they never reach storage directly.

Import adapts a whole batch of files into per-file WorldWrite values, each carrying origin: Imported and lifecycle: Draft. See data-flow/import for the batch shape.


The boundary is a single domain-layer validator. It enforces, at minimum:

  • Well-formedness. Required fields are present and typed; origin and lifecycle are valid enum values.
  • Origin consistency. The caller is allowed to claim this origin. Agent callers cannot claim Authored; import callers cannot claim AgentProduced.
  • Lifecycle transitions. The requested lifecycle is a valid transition from the target’s current state, per domain rule 3. The agent cannot silently self-promote content from Candidate to Canonical.
  • Derivation internality. Derivation sources are always workspace entities referenced by workspace identifier. Cross-workspace derivation is refused.
  • Capability. The capability the caller claims matches the capability required by the operation; denials produce capability errors, not deviations.

On acceptance, the validator applies the write through the repository in a single SQL transaction. Any derivation links declared in the WorldWrite are inserted in the same transaction (derivation_link rows referencing the newly-written page and its sources).

On conflict with canonical content (the four conflict types named in systems/world/deviation-records), the validator produces a DeviationRecord alongside (or instead of) applying the write per domain rule 5. Every conflict produces a record — no agent self-assessment suppresses creation per ADR-019. The deviation record, the deviation_content_ref rows pointing at the involved content, and any applied content changes are written in the same transaction.

On refusal (malformed write, origin violation, capability denial), no content is modified and the caller receives a typed error. Capability denials do not produce deviation records.


Code: crates/infrastructure/sqlite/src/workspace/page/ (and siblings for other content families)

A single SQL transaction writes the content change and any world-model side records:

TableColumn(s) writtenContent
pagesraw_markdown, origin, lifecycle, origin_source_id, origin_at, …Author/agent/import write updates the page row; new rows carry full provenance
blockscontent_loro, content, content_typeEditor writes store the LoroDoc blob; agent block writes also store a blob (agent-produced blocks are still Loro-backed)
derivation_linkallInserted when the WorldWrite declares derivation sources
deviation_recordallInserted when the submission conflicts with canonical content
deviation_content_refallInserted alongside the deviation record, one row per involved content

The editor-specific invariant holds: the CRDT blob is written as received; never re-serialized from materialized text. The text is a derived projection used by FTS5 and debug tooling only.

FTS5 index triggers on pages.raw_markdown fire automatically when the column updates. See database-schema §fts5-virtual-tables.


Stage 4: WriteEffectCoordinator side effects

Section titled “Stage 4: WriteEffectCoordinator side effects”

Code: apps/desktop/src-tauri/src/side_effects.rs

After the transaction commits, the WriteEffectCoordinator fires a set of idempotent, best-effort side effects. They run in parallel and are fire-and-forget: a side-effect failure is logged but does not roll back the write.

RecordEventUseCase appends an EventLogEntry for the primary mutation (e.g., EntityType::Block, EventType::Updated) and for any side records produced (e.g., EntityType::DerivationLink on create, EntityType::DeviationRecord on create or status transition). Event-log entries carry device_id and event-source metadata so the event-log UI can filter scheduled-autonomous-task-originated entries as first-class per the PL-B resolution.

task.events.try_send(EmbeddingEvent::EmbedPage { page_id })

EmbeddingManager receives the event via a bounded channel and schedules the page for re-embedding. Pending events are deduplicated (a HashSet prevents queuing the same page multiple times). When the embedding runs it reads the current raw_markdown, generates a 768-dimensional vector via the ONNX Runtime (snowflake-arctic-embed-m-v2.0), and upserts into page_embeddings.

When a write modifies content that has derived descendants (i.e., there are derivation_link rows with this page as source_page_id), the coordinator creates revalidation_flag rows for each derived page per domain rule 6 and the standing-scaled mechanics in systems/world/retroactive-revision. The prominence field is snapshotted at flag-creation time from the source’s current standing. The world model does not auto-rewrite derived content; flags are processed by the author (or by a scheduled autonomous task) from the re-validation queue.

Flag creation runs inside the same transaction as the write in the common case, so a crash between write and flag is not observable; if it did occur, the coordinator’s idempotent retry would re-create the flag.

The coordinator enqueues a sync entry via SyncQueueRepository::enqueue(). The sync engine drains this queue in background cycles and pushes updates to Supabase when cloud sync is configured. Sync applies uniformly to content tables and world-model primitive tables (derivation links, deviation records, re-validation flags all sync).


The same four-stage flow is instantiated by several caller paths. The differences are concentrated in Stage 1 (adapter).

  1. User types; LoroSyncPlugin updates the LoroDoc.
  2. User saves; React exports blob and text.
  3. invoke("save_block_content", { slug, blob, text }).
  4. Tauri command validates at the system boundary (non-empty blob, valid slug format) and resolves the workspace and permission guard.
  5. Editor adapter constructs a WorldWrite with origin: Authored, the author’s chosen lifecycle, and the content payload.
  6. Stages 2–4 run as above.
  1. Planner produces a tool call; LangGraph dispatches it via MCP to a Rust tool.
  2. MCP adapter constructs a WorldWrite with origin: AgentProduced, lifecycle: Candidate (per submit-boundary), and derivation sources from the tool’s registration metadata.
  3. Stages 2–4 run as above.
  4. The tool returns a ToolResult over MCP carrying the applied write’s identity and any generated deviation record ids.

See data-flow/agent-session.

Identical to the agent write path, except the run was initiated by the task-runner rather than by a user prompt. The write carries origin: AgentProduced and a device_id / event-source metadata distinguishable in the event log.

For each source file, the import adapter constructs a WorldWrite with origin: Imported, lifecycle: Draft, and an origin_source_id tying the write back to the import run. See data-flow/import for the batch flow and the bulk-canonicalize affordance.

The Wasmtime-sandboxed CPython executor is exposed as an MCP tool — a capability, not a runtime, per ADR-021. Any write it performs goes through the same MCP adapter as any other agent write and receives origin: AgentProduced when invoked by the agent, or the caller’s origin otherwise. The sandbox is not a back door.


FailureBehavior
Empty blobValidation error returned before any DB write
Origin inconsistent with callerDomainError; no write; caller receives typed error
Invalid lifecycle transitionDomainError; no write
Derivation source is not a workspace entityDomainError; no write
Capability deniedTyped capability error; no write; no deviation record per domain rule 7
Conflict with canonical contentDeviationRecord produced; write may or may not apply depending on conflict type; caller receives WorldWriteResult { applied, deviation_ids }
SQLite write failureInternal error; no side effects fire
Embedding queue fulltry_send drops the event; next save will re-queue
Event log failureLogged and discarded; write is not rolled back
Re-validation flag creation failureLogged; idempotent retry re-creates the flag
Sync-queue enqueue failureLogged; the write is not rolled back; the next coordinator pass re-enqueues

Was this page helpful?