DB

The program that hosts the field. The substrate is delivered to consumers through this program; SQLite is the substrate's persistent body underneath.

A single Rust crate, compiled into the host binary. Owns one SQLite database file per project at .ol/db. No in-memory cache that can drift from disk — SQLite is the single source of truth.

The substrate spec defines what the field IS. This document defines two contracts:

Both answer to pilot/substrate.md. Where they disagree, the substrate spec is right.


Consumer contract

Methods on the Db handle. Synchronous in surface (SQLite calls block); change stream is async.

Lifecycle

Db::open(project_path: &Path) -> Result<Db, OpenError>

Initializes the SQLite connection (creates the file with migrations if fresh, opens with journal_mode = WAL if existing), seeds the minimum the db needs, returns the handle. Closes via Drop.

The db's own bootstrap is small: one row in branches (the bootstrap branch, main) and one initial commit in commits. The substrate's archetypes for branches and commits, and the scope-anchors branches_root and commits_root, are projected by the read layer with hardcoded shapes — not stored as chunks. Field content (engine root scope, ui root scope, agent root scope, archetype seeds, etc.) is not the db's concern; the host's bootstrap routine writes those via db.commit() after Db::open returns.

Reads

Two operations. Everything readable from the field goes through them.

impl Db {
  fn scope(&self, scopes: &[ChunkId], opts: ScopeOpts)
       -> Result<ScopeResult, ReadError>;

  fn get(&self, chunk_id: ChunkId, opts: ReadOpts)
       -> Result<Option<ChunkItem>, ReadError>;
}

scope returns the intersection of the named scopes — chunks placed on every one of them. get returns a single chunk by id, or None if not present in current state.

ScopeOpts:

ScopeOpts
  branch: BranchName            default "main"
  at: Option<CommitId>          time travel
  match_: Option<String>        FTS5 filter applied within the intersection
  limit: Option<usize>          pagination
  offset: Option<usize>
  include: Includes             what to populate

scopes may be empty. Empty scope means the whole field — every chunk qualifies for the intersection (vacuous truth). Combined with match_, it is field-wide search; combined with Includes::shape(), it is the browser's starting view (all dims sorted by count); combined with intersection_chunks plus limit, it enumerates the field. Pagination is the guardrail against unbounded fetches, not a runtime restriction.

When scopes is empty, vacuous truth applies uniformly: in_scope = in_scope_instance = in_scope_relates = total. Every chunk is trivially "placed instance on every queried scope" and "placed relates on every queried scope" when no scopes are queried (the empty conjunction is true). Consumers should treat the instance/relates split as degenerate (uninformative) under empty scope — it is reported for consistency with the non-empty case, not as useful attribution.

Result

ScopeResult
  head                  commit sampled
  total                 chunks in branch
  in_scope              chunks at intersection
  in_scope_instance     ...via instance on every dim in scope
  in_scope_relates      ...via relates on every dim in scope
  chunks: [ChunkItem]   intersection chunks (opt-in)
  dimensions: [Dim]     scopes you can add (opt-in)
ChunkItem
  id                                      always
  name?  spec?  body?  placements?       chunk self-data (opt-in)

Dim
  id, name
  count                                   chunks at intersection placed here
  instance
  relates
  edges?: [Edge]                          scopes you can reach from this dim,
                                          beyond current adjacency (opt-in)

Edge
  id, name
  count                                   chunks on this dim also placed on the edge dim
  instance
  relates

Placement
  scope_id, type_, seq?

Spec
  ordered, accepts, required, unique, propagate

Folk reading:

Why dimensions and edges differ: dimensions are scopes intersection chunks already touch — adding any keeps the intersection non-empty (narrowing). Edges are scopes a dim's chunks (including chunks NOT at the current intersection) touch beyond the current adjacency — reachable only by stepping out of the current scope.

Sort: dimensions and Dim.edges both descending by count.

Includes

Includes                                  default: every flag false

  chunk_name  chunk_spec  chunk_body  chunk_placements    per ChunkItem

  intersection_chunks                     populate `chunks`
  dimensions                              populate `dimensions`
  edges                                   also populate `Dim.edges`

  rank  snippet                           with match_

Minimum return when nothing is opted in: head, four counts, empty chunks, empty dimensions.

Convenience constructors:

Includes::shape()      = { dimensions }
Includes::content()    = { intersection_chunks, chunk_name, chunk_body, chunk_placements }
Includes::all()        = every flag

Branches and commits as virtual chunks

The substrate's discipline is that everything is chunks and placements. Branches and commits are projected by the read layer as virtual chunks — they appear in scope and get like any other content:

branches_root and commits_root are well-known ids recognized by the read layer. They are not stored — they are projection anchors with hardcoded specs (the branch and commit archetypes).

Virtual chunks are read-only via scope/get. Writes targeting them are rejected (WriteToVirtualChunk). Their state is owned by db-level operations: commit (advances a branch's head), create_branch / delete_branch (manipulate the branch graph).

There is no list_branches, current_head, or history operation — they are just scope reads against the virtual anchors.

Write

One operation produces commits.

impl Db {
  fn commit(&self, declaration: &Declaration, opts: CommitOpts)
       -> Result<Commit, WriteError>;
}
Declaration
  chunks: [ChunkDeclaration]
  placements: [PlacementSpec]      bare placements (no chunk content change)
  message: Option<String>

CommitOpts
  branch: BranchName               which branch this commit lands on
  dispatch_id: Option<String>      engine metadata, propagated to the commit chunk

The whole declaration is one transaction. All writes succeed and a commit is recorded, or all fail and nothing is written.

The result is the Commit itself — a chunk-shaped artifact:

Commit
  id, parent_id?, timestamp, message?, dispatch_id?
  chunks_modified:     [ChunkId]
  placements_modified: [(ChunkId, ChunkId)]    (chunk_id, scope_id) entered or left

chunks_modified and placements_modified are the deltas — for caller convenience and for filtering on the change stream.

Branch operations

impl Db {
  fn create_branch(&self, name: &str, from: CommitId) -> Result<Branch, WriteError>;
  fn delete_branch(&self, name: &str) -> Result<(), WriteError>;
}

create_branch makes a new branch pointer at an existing commit. delete_branch removes the pointer; commits remain (lossless). Both emit on the change stream — branch graph mutations surface alongside commits.

The change stream

impl Db {
  fn subscribe_scope(&self, scopes: &[ChunkId], opts: SubscribeOpts)
       -> impl Stream<Item = Commit>;
}

A single subscription primitive. Yields commits that touch the named scopes (any of them). Backed by an internal broadcast channel pushed from Rust right after tx.commit() returns Ok (see Reactivity wiring); state and event are tightly coupled — by the time the event arrives, the SQL commit is durable and visible to any reader.

Subscribe at any scope to listen there:

Backpressure: each subscriber has a bounded receiver. On overflow, oldest events drop and a Lagged marker is emitted. Subscriptions are tied to the handle's Db lifetime; dropping the stream unsubscribes.

Errors

ValidationError { scope_id, kind }     spec violation; kind = Ordered | Accepts | Required | Unique | AmbiguousType
NameCollision { scope_id, name }       name uniqueness rule
NotFound { kind, id }                  chunk, branch, or commit not present
MalformedDeclaration(reason)           declaration self-inconsistent
WriteToVirtualChunk { id }             declaration targets a projected chunk
IoError(SqliteError)                   underlying SQLite error

Atomicity

A declaration is one transaction. Inside:

  1. Insert version rows for everything in the declaration.
  2. Apply current-state transitions (FTS triggers fire).
  3. Run validation against the post-write current state.
  4. If validation passes: insert the commit row, advance the branch HEAD, COMMIT, push to the change stream.
  5. If validation fails: ROLLBACK. Nothing recorded; nothing emitted.

Writes within a declaration are visible to validation through ordinary SELECTs (the post-write state lives in current-state tables inside the transaction), but invisible to other transactions until COMMIT. The substrate's two-pass write-then-validate is delivered by SQLite transaction semantics directly.

A commit row appears only when validation passes. The change stream emits only successful commits.


SQLite contract

Physical schema

Two layers. Version tables are append-only history (the source of truth for time travel). Current-state tables are materialized views maintained on each commit (the read path).

CREATE TABLE commits (
  id           TEXT PRIMARY KEY,                 -- sortable ULID-shaped id
  parent_id    TEXT REFERENCES commits(id),
  timestamp    TEXT NOT NULL,                    -- ISO-8601 UTC
  message      TEXT,
  dispatch_id  TEXT
);

CREATE TABLE branches (
  name TEXT PRIMARY KEY,
  head TEXT NOT NULL REFERENCES commits(id)
);

CREATE TABLE chunk_versions (
  chunk_id   TEXT NOT NULL,
  commit_id  TEXT NOT NULL REFERENCES commits(id),
  name       TEXT,
  spec       TEXT NOT NULL DEFAULT '{}',         -- JSON
  body       TEXT NOT NULL DEFAULT '{}',         -- JSON
  removed    INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (chunk_id, commit_id)
);

CREATE TABLE placement_versions (
  chunk_id   TEXT NOT NULL,
  scope_id   TEXT NOT NULL,
  commit_id  TEXT NOT NULL REFERENCES commits(id),
  type       TEXT NOT NULL CHECK (type IN ('instance', 'relates')),
  seq        INTEGER,
  active     INTEGER NOT NULL DEFAULT 1,
  PRIMARY KEY (chunk_id, scope_id, commit_id)
);

CREATE TABLE current_chunks (
  chunk_id  TEXT NOT NULL,
  branch    TEXT NOT NULL REFERENCES branches(name),
  name      TEXT,
  spec      TEXT NOT NULL DEFAULT '{}',
  body      TEXT NOT NULL DEFAULT '{}',
  PRIMARY KEY (chunk_id, branch)
);

CREATE TABLE current_placements (
  chunk_id  TEXT NOT NULL,
  scope_id  TEXT NOT NULL,
  branch    TEXT NOT NULL REFERENCES branches(name),
  type      TEXT NOT NULL,
  seq       INTEGER,
  PRIMARY KEY (chunk_id, scope_id, branch)
);

CREATE VIRTUAL TABLE chunk_fts USING fts5(
  name,
  body,
  content='current_chunks',
  content_rowid='rowid',
  tokenize='unicode61'
);

Indexes

CREATE INDEX idx_current_placements_scope ON current_placements(scope_id, branch, type);
CREATE INDEX idx_current_placements_chunk ON current_placements(chunk_id, branch);
CREATE INDEX idx_chunk_versions_chunk     ON chunk_versions(chunk_id, commit_id);
CREATE INDEX idx_placement_versions_chunk ON placement_versions(chunk_id, scope_id, commit_id);
CREATE INDEX idx_commits_parent           ON commits(parent_id);

FTS hookup

Triggers on current_chunks keep the FTS index synchronized within the commit transaction:

CREATE TRIGGER current_chunks_ai AFTER INSERT ON current_chunks BEGIN
  INSERT INTO chunk_fts(rowid, name, body) VALUES (new.rowid, new.name, new.body);
END;

CREATE TRIGGER current_chunks_ad AFTER DELETE ON current_chunks BEGIN
  INSERT INTO chunk_fts(chunk_fts, rowid, name, body)
    VALUES ('delete', old.rowid, old.name, old.body);
END;

CREATE TRIGGER current_chunks_au AFTER UPDATE ON current_chunks BEGIN
  INSERT INTO chunk_fts(chunk_fts, rowid, name, body)
    VALUES ('delete', old.rowid, old.name, old.body);
  INSERT INTO chunk_fts(rowid, name, body) VALUES (new.rowid, new.name, new.body);
END;

The FTS index covers all branches' current state; branch filtering is a JOIN at query time.

Tokenization. body is stored and indexed as JSON text. The unicode61 tokenizer splits on word boundaries — punctuation including {, }, ", :, , is treated as separators — so a query like match_: "world" matches body = {"greeting": "hello world"}. The flip side: tokens from JSON keys are not distinguished from values, so match_: "greeting" would also match. The pilot accepts this as a "search over chunk text content" semantic, not a structured query — programs that need keyed search compose substrate queries from scopes and dimensions, not FTS.

The commit algorithm

commit(declaration, opts):

  reject if any chunk in the declaration targets a virtual chunk
    (branches_root, commits_root, branch archetype, commit archetype) → WriteToVirtualChunk

  BEGIN IMMEDIATE TRANSACTION

  let commit_id = generate_commit_id()
  let parent    = head_of(opts.branch)
  let timestamp = now_utc()

  INSERT INTO commits (id, parent_id, timestamp, message, dispatch_id)
  VALUES (commit_id, parent, timestamp, declaration.message, opts.dispatch_id)

  for each chunk in declaration.chunks:
    resolve id (declared or generated)
    INSERT INTO chunk_versions (chunk_id, commit_id, name, spec, body, removed)
    apply current-state transition for opts.branch

  for each placement (chunk-bound and bare):
    INSERT INTO placement_versions (chunk_id, scope_id, commit_id, type, seq, active)
    apply current-state transition for opts.branch

  validate in Rust against post-write current state on this branch:
    for each chunk touched, compose effective contract and check rules

  any failure => ROLLBACK and return

  UPDATE branches SET head = commit_id WHERE name = opts.branch
  COMMIT
  (after tx.commit() returns Ok, push Commit to broadcast channel)

  return Commit

Validation is in Rust. SQL stores; Rust enforces. Validating in SQL would require recursive CTEs over the propagating-archetype graph and lock the rule into SQL; Rust gives clearer code and easier evolution. Rules read through ordinary SELECTs against the open transaction's connection, not pre-fetched into pure structs — the post-write state already lives in current_chunks / current_placements inside the transaction.

Current-state transitions

For each chunk_versions row at branch B:

chunk_versions row current_chunks rule (branch B)
removed = 0 UPSERT row with new (name, spec, body)
removed = 1 DELETE current_chunks row; DELETE all current_placements rows where chunk is the chunk OR the scope (branch B only)

For each placement_versions row at branch B:

placement_versions row current_placements rule (branch B)
active = 1 UPSERT row with (type, seq); auto-assign seq when scope is ordered: true and seq omitted (see below)
active = 0 DELETE row for (chunk_id, scope_id, branch B)

Removal is per-branch.

Seq auto-assignment. When ordered: true and seq is omitted, the assignment is max(seq) + 1 over current_placements for that (scope_id, branch), evaluated as each placement is applied (not in batch). Within a single declaration that places multiple chunks on the same ordered scope without seq, the assignments run sequentially: the second sees the first's just-applied row, gets max + 2, etc. Across concurrent commits, BEGIN IMMEDIATE serializes writes, so one commit's auto-assigned seqs are visible to the next before its max lookup runs.

Query patterns

Intersection (the chunks)

SELECT cc.*
FROM current_chunks cc
JOIN current_placements cp
  ON cp.chunk_id = cc.chunk_id AND cp.branch = cc.branch
WHERE cc.branch = :branch
  AND cp.scope_id IN (:scope_ids)
  AND cp.type = 'instance'
GROUP BY cc.chunk_id
HAVING COUNT(DISTINCT cp.scope_id) = :n_scopes;

With match_, intersect against FTS:

SELECT cc.*, fts.rank
FROM chunk_fts fts
JOIN current_chunks cc ON cc.rowid = fts.rowid
JOIN current_placements cp
  ON cp.chunk_id = cc.chunk_id AND cp.branch = cc.branch
WHERE chunk_fts MATCH :query
  AND cc.branch = :branch
  AND cp.scope_id IN (:scope_ids)
  AND cp.type = 'instance'
GROUP BY cc.chunk_id
HAVING COUNT(DISTINCT cp.scope_id) = :n_scopes
ORDER BY fts.rank;

Empty scope. When scopes is empty, in_scope is "every chunk on this branch" — the placement join is dropped:

SELECT cc.*
FROM current_chunks cc
WHERE cc.branch = :branch
LIMIT :limit OFFSET :offset;

With match_ added, intersect against FTS as above but again without the placement join.

For ordered scopes (non-empty case), ORDER BY cp.seq with LIMIT/OFFSET. Pagination is position-based on the ordered result set, not seq-value-based: LIMIT 10 OFFSET 20 returns the chunks at positions 21–30 in the seq order, regardless of how sparse seq values are.

Dimensions

For each scope the intersection chunks are placed on, with counts split by placement type:

WITH in_scope AS (
  SELECT cp.chunk_id
  FROM current_placements cp
  WHERE cp.branch = :branch
    AND cp.scope_id IN (:scope_ids)
    AND cp.type = 'instance'
  GROUP BY cp.chunk_id
  HAVING COUNT(DISTINCT cp.scope_id) = :n_scopes
)
SELECT
  cp.scope_id,
  COUNT(*) FILTER (WHERE cp.type = 'instance') AS instance_count,
  COUNT(*) FILTER (WHERE cp.type = 'relates')  AS relates_count,
  COUNT(*) AS total
FROM current_placements cp
JOIN in_scope ON in_scope.chunk_id = cp.chunk_id
WHERE cp.branch = :branch
GROUP BY cp.scope_id
ORDER BY total DESC;

(Dimensions include the scopes in the input — they qualify trivially. The consumer filters them out only if they want the "what to add" view excluding the input.)

When scopes is empty, the in_scope CTE collapses to "every chunk on this branch" — every dim in the field appears in dimensions, sorted by count. Edges become empty in this case: with empty input, every dim is already adjacent, so there is nothing "beyond."

Edges (for each dim, what it reaches beyond)

-- for each adjacent dim X, find dims Y that any chunk on X also touches,
-- where Y is not in the current scope and not already adjacent
SELECT
  cm1.scope_id AS from_dim,
  cm2.scope_id AS to_dim,
  COUNT(*) FILTER (WHERE cm2.type = 'instance') AS instance_count,
  COUNT(*) FILTER (WHERE cm2.type = 'relates')  AS relates_count,
  COUNT(*) AS total
FROM current_placements cm1
JOIN current_placements cm2 ON cm1.chunk_id = cm2.chunk_id AND cm2.branch = cm1.branch
WHERE cm1.branch = :branch
  AND cm1.scope_id IN (:dimension_ids)        -- adjacent dims from previous query
  AND cm2.scope_id NOT IN (:scope_ids)
  AND cm2.scope_id NOT IN (:dimension_ids)
  AND cm1.scope_id != cm2.scope_id
GROUP BY cm1.scope_id, cm2.scope_id
ORDER BY total DESC;

Virtual chunks (branches and commits)

When scope is called with branches_root or commits_root, the read layer projects from the underlying tables instead of joining current_chunks:

-- branches projection
SELECT name AS chunk_id, name, '{}' AS spec,
       json_object('head', head) AS body
FROM branches;

-- commits in a branch's ancestry, ordered by depth
WITH RECURSIVE ancestry(id, depth) AS (
  SELECT head, 0 FROM branches WHERE name = :branch
  UNION ALL
  SELECT c.parent_id, a.depth + 1
  FROM commits c JOIN ancestry a ON c.id = a.id
  WHERE c.parent_id IS NOT NULL
)
SELECT c.id AS chunk_id, NULL AS name, '{}' AS spec,
       json_object(
         'timestamp',   c.timestamp,
         'message',     c.message,
         'dispatch_id', c.dispatch_id
       ) AS body,
       a.depth AS seq
FROM commits c JOIN ancestry a ON c.id = a.id
ORDER BY seq;

Mixing virtual and real scopes in one scope call is rejected in the pilot — keeps the projection clean.

Time travel

When at: Some(commit_id) is set, the current-state path is bypassed; the read walks version tables:

WITH RECURSIVE ancestry(id, depth) AS (
  SELECT :target, 0
  UNION ALL
  SELECT c.parent_id, a.depth + 1
  FROM commits c JOIN ancestry a ON c.id = a.id
  WHERE c.parent_id IS NOT NULL
),
chunk_state AS (
  SELECT cv.*,
         ROW_NUMBER() OVER (
           PARTITION BY cv.chunk_id
           ORDER BY (SELECT depth FROM ancestry WHERE id = cv.commit_id) ASC
         ) AS rn
  FROM chunk_versions cv
  WHERE cv.commit_id IN (SELECT id FROM ancestry)
)
SELECT * FROM chunk_state WHERE rn = 1 AND removed = 0;

(Sketch — exact query refines under benchmark.)

Reactivity wiring

The Db handle holds a tokio::sync::broadcast::Sender<Commit>. Each successful write op (commit, create_branch, delete_branch) pushes the resulting Commit onto the channel immediately after tx.commit() returns Ok. Subscribers (subscribe_scope) hold receivers and filter on placements_modified/chunks_modified.

By the time the push runs, the SQL commit is durable and visible to any subsequent reader — atomic from any observer's perspective. Rolled-back transactions never reach the push, so subscribers see only durable commits.

The channel is bounded; on overflow the oldest event drops and a Lagged marker reaches the subscriber so they can re-read from a known commit if needed. The push itself is non-blocking — slow consumers do not block the writer.

Transaction discipline

All writes use BEGIN IMMEDIATE to acquire the SQLite write lock up front — no deadlock-by-upgrade. Reads use the default deferred mode and benefit from WAL's reader-doesn't-block-writer behavior. Open settings: journal_mode = WAL, synchronous = NORMAL.


Concurrency

SQLite in WAL mode gives single-writer, many-reader. The db inherits this; nothing is invented on top.

One writer at a time. Concurrent commit calls serialize at the SQLite level. Default busy timeout is 5 seconds; on timeout the call fails and the caller decides whether to retry.

Readers do not block writers; writers do not block readers. A read started during a write reads from a snapshot taken at the read's start; the in-progress write is invisible until it commits.

Per-call read consistency. Each read runs in its own implicit transaction — consistent within one call, may shift between consecutive calls.

Cross-call read consistency. Open. The engine's most demanding read pattern fits in a single multi-scope scope call; explicit snapshot handles may not be needed for the pilot.

Reactivity does not block writes. The change-stream channel is bounded and non-blocking on push; slow consumers drop the oldest with a Lagged marker.


Code architecture

Module layout

pilot/db/
  src/
    lib.rs                 — public re-exports
    types.rs               — ChunkId, CommitId, ChunkItem, Spec, Commit, Includes,
                             ScopeOpts, ScopeResult, Dim, Edge, Placement,
                             Declaration, ChunkDeclaration, PlacementSpec,
                             ReadOpts, CommitOpts, BranchName, Branch, SubscribeOpts
    errors.rs              — per-op error enums (OpenError, ReadError, WriteError, ...) via thiserror
    schema.rs              — embedded DDL via include_str! + rusqlite_migration list
    schema.sql             — DDL: tables, indexes, FTS triggers
    id.rs                  — ULID-shaped id generation (`ulid` crate)
    db.rs                  — Db { conn: Mutex<Connection>, sender: broadcast::Sender<Commit> }
                             Db::open, Drop
    validate.rs            — Rule enum + check_commit; effective-contract composition
    virtual_chunks.rs      — branches_root / commits_root projection (used by ops::scope, ops::get)
    bootstrap.rs           — initial seed on fresh open (main branch + initial commit)
    ops/                   — public surface; one module per Db method
      mod.rs               — re-exports
      get.rs               — Db::get
      commit.rs            — Db::commit; transitions inline
      branches.rs          — Db::create_branch, Db::delete_branch
      subscribe.rs         — Db::subscribe_scope (BroadcastStream + scope filter)
      scope/               — folded because of size: four distinct query paths
        mod.rs             — Db::scope orchestrator; opts/result plumbing
        intersection.rs    — chunks query (with/without FTS, with/without empty scope, hydration)
        dimensions.rs      — dimensions CTE
        edges.rs           — edges-beyond-adjacency
        time_travel.rs     — `at: Some(commit)` ancestry walk
  tests/                   — integration tests; oracle-checked vs the TS suite
  Cargo.toml

Each ops/*.rs owns its method end-to-end via impl Db { pub fn ... }. Rust spreads impl Db across files — that keeps ops/ flat where it can be flat. scope/ is folded because the public method fans into four distinct query paths.

Pattern repeated in every feature file

// 1. SQL as `const`s at the top — declarative, scannable.
const INTERSECTION: &str = "...";

// 2. The public method on Db. Reads top-to-bottom; narrates the flow.
impl Db {
    pub fn scope(&self, scopes: &[ChunkId], opts: ScopeOpts)
        -> Result<ScopeResult, ReadError> { ... }
}

// 3. Private free functions for shape: param prep, row mapping.
fn row_to_chunk_item(row: &Row) -> Result<ChunkItem> { ... }

git diff between any two feature files reads as the same shape with different verbs. Coherence through pattern, not folder.

Key mechanics

Connection ownership. Db { conn: Mutex<Connection>, sender: broadcast::Sender<Commit> }. Every op locks before touching the connection. SQLite is single-writer anyway; the mutex is uncontested in practice and gives Db: Send + Sync for free. Subscribers hold their own broadcast::Receiver; they do not retain &Db.

Transactions. No custom RAII helper. conn.transaction_with_behavior(TransactionBehavior::Immediate)? returns rusqlite's Transaction — Drop = ROLLBACK; explicit tx.commit() advances. Used directly inside ops::commit and ops::branches.

Reactivity push. After tx.commit() returns Ok, the op pushes the resulting Commit onto db.sender. Three call sites: ops::commit, ops::branches::create_branch, ops::branches::delete_branch. Two lines each; repetition over a wrapper. By the time the push runs, the SQL commit is durable and visible to any subsequent reader.

Validation. Rule enum with one variant per rule (Ordered, Accepts, Required, Unique, NameUnique); match dispatches inside check_commit(conn, branch, touched). Adding a rule = adding a variant. Reads run through ordinary SELECTs against the open transaction's connection — no pre-fetch into pure structs.

Errors. thiserror. Per-op enums (OpenError, ReadError, WriteError) with shared variants (e.g. IoError(rusqlite::Error)) duplicated across them — dumb-but-clear over a single mega-enum.

IDs as newtypes. ChunkId(String), CommitId(String), BranchName(String) with From<&str> and Display.

Sync surface, async reactivity. scope, get, commit, create_branch, delete_branch are sync (SQLite is sync). subscribe_scope returns a Stream — async, the natural shape for change feeds (via tokio_stream::wrappers::BroadcastStream).

Settled choices


What is open