Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
resolver = "3"
members = [
"runtime/component",
"runtime/crates/document-history",
"runtime/crates/engine",
"runtime/crates/kernel",
"runtime/crates/todo-model",
"runtime/crates/visor-model",
"visor",
"apps/todomvc",
# Not shipped by the production site build: `web/build.ts --fixtures`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ confinement.
|---|---|
| `wit/` | `polyvisor:app` — the public contract, versioned deliberately |
| `runtime/wit/` | `polyvisor:internal` — the private contract between this repo's own components and glue |
| `runtime/` | the `runtime` component: `crates/kernel` (devices, sealing, checkpoints, pairing, sessions), `crates/engine` (automerge over subduction's sans-IO node), `component/` (the world, the iroh transport) |
| `runtime/` | the `runtime` component: `crates/kernel` (devices, sealing, checkpoints, pairing, sessions), `crates/engine` (history sync over subduction's sans-IO node), `crates/document-history` (shared Automerge adapter), `crates/todo-model` and `crates/visor-model` (domain schemas), `component/` (the world, the iroh transport) |
| `visor/` | the `visor` component (trusted pixels) |
| `apps/` | example/reference apps (`todomvc`) |
| `web/` | glue TypeScript |
Expand Down
12 changes: 10 additions & 2 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,16 @@ There is no special "data model component" kind or elevated runtime trust tier.
bindings and supplies trustworthy caller context; the provider enforces
domain policy.

This is the intended split; task semantics and Automerge integration currently
reside in the engine. The history WIT interface is not yet designed.
The first, trusted-Rust stage of this split is in place. `todo-model` owns the
task schema and operations, `visor-model` owns the encrypted visor route,
install, personalization, and adoption policy, and both operate on the
`document-history` crate's Automerge document adapter. The engine owns one live
document per partition plus subscriptions, encrypted publication/receipt,
in-memory history storage, compaction, and history snapshots; the kernel
composes model operations with that schema-neutral Rust interface and schedules
browser checkpoint persistence. These crates still run together inside the
trusted runtime component. No history WIT interface or separate provider
component is introduced yet.

## Sync engine: subduction sans-IO

Expand Down
12 changes: 12 additions & 0 deletions runtime/crates/document-history/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[package]
name = "polyvisor-document-history"
version = "0.1.0"
edition.workspace = true
license.workspace = true
publish.workspace = true

[dependencies]
automerge.workspace = true
sedimentree_core.workspace = true
sha2 = "0.10"
subduction_protocol.workspace = true
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ use automerge::{ActorId, Automerge};
use sedimentree_core::{blob::Blob, id::SedimentreeId, loose_commit::id::CommitId};
use subduction_protocol::command::NewCommit;

use crate::storage::SnapshotStorage;

/// What one batch of [`Document::apply`] did.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Absorbed {
Expand All @@ -46,8 +44,9 @@ pub struct Document {
/// "must the kernel checkpoint?" — answerable without re-decoding every
/// stored blob.
applied: BTreeSet<CommitId>,
/// The commit for the last local change, until the engine takes it.
pending: Option<NewCommit>,
/// Local changes not yet handed to the history publisher, in transaction
/// order. A model callback may deliberately author more than one change.
pending: Vec<NewCommit>,
}

impl Document {
Expand All @@ -57,7 +56,7 @@ impl Document {
doc: Automerge::new().with_actor(actor),
tree,
applied: BTreeSet::new(),
pending: None,
pending: Vec::new(),
}
}

Expand Down Expand Up @@ -95,7 +94,7 @@ impl Document {
doc,
tree,
applied,
pending: None,
pending: Vec::new(),
}
}

Expand All @@ -104,6 +103,11 @@ impl Document {
&self.doc
}

/// This document's per-device, per-partition Automerge actor identifier.
pub fn actor_id(&self) -> &[u8] {
self.doc.get_actor().to_bytes()
}

pub const fn tree(&self) -> SedimentreeId {
self.tree
}
Expand Down Expand Up @@ -132,26 +136,9 @@ impl Document {
self.doc.get_changes_meta(&[]).len() as u64
}

/// Take the sedimentree commit for the last local change, if the last
/// mutation produced one.
pub fn last_local_commit(&mut self) -> Option<NewCommit> {
self.pending.take()
}

/// Apply every stored change of this tree the document has not seen.
/// Returns whether anything landed. The plaintext path, for the
/// user-system document — an app document's blobs are envelopes, and the
/// engine decrypts them before calling [`Document::apply`].
///
/// Fragments first. Automerge buffers a change whose dependencies are
/// missing either way, so the order is not required for correctness; it
/// is cheaper, because a bundle that lands first makes every loose commit
/// it carries a no-op instead of a second decode.
pub fn absorb(&mut self, storage: &SnapshotStorage) -> bool {
let bundles = self.unapplied_fragments(storage);
let fragments = self.apply_bundles(bundles);
let items = self.unapplied(storage);
fragments.landed | self.apply(items).landed
/// Take all unpublished local commits in transaction order.
pub fn drain_local_commits(&mut self) -> Vec<NewCommit> {
std::mem::take(&mut self.pending)
}

/// The commits this document has already applied. The vault needs them to
Expand All @@ -162,28 +149,11 @@ impl Document {
self.applied.clone()
}

/// The stored blobs of this tree the document has not applied yet, raw.
/// For an app document these are keyhive envelopes.
pub fn unapplied(&self, storage: &SnapshotStorage) -> Vec<(CommitId, Vec<u8>)> {
storage
.commit_blobs(self.tree)
.into_iter()
.filter(|(id, _)| !self.applied.contains(id))
.collect()
}

/// The stored *fragment* blobs of this tree the document has not applied.
///
/// A fragment is skipped once its head is applied, and that is exact
/// rather than approximate: the head is a member of the fragment
/// (automerge `change_graph.rs:1661` — `members` is the section the head
/// closes), so a document that has the head has been through this bundle.
pub fn unapplied_fragments(&self, storage: &SnapshotStorage) -> Vec<(CommitId, Vec<u8>)> {
storage
.fragment_blobs(self.tree)
.into_iter()
.filter(|(head, _)| !self.applied.contains(head))
.collect()
/// Whether this history already contains `id`. The engine uses this to
/// select stored encrypted items before it releases its document borrow
/// and awaits decryption.
pub fn contains(&self, id: &CommitId) -> bool {
self.applied.contains(id)
}

/// Apply fragment payloads: automerge *bundles*, each carrying every
Expand Down Expand Up @@ -248,7 +218,7 @@ impl Document {
///
/// For the one caller that has to describe a whole document as a single
/// sedimentree fragment rather than take automerge's own partition of it
/// (`crate::Engine::adopt_fragment`).
/// when publishing an adopted document as one fragment.
pub fn heads(&self) -> Vec<automerge::ChangeHash> {
self.doc.get_heads()
}
Expand Down Expand Up @@ -279,12 +249,7 @@ impl Document {
/// own merge commit (`Automerge::empty_commit` — "the main reason to do
/// this is if you want to create a merge commit").
///
/// It carries no operations, so it changes nothing anyone reads. What it
/// carries is its *envelope*: sealed under the group's current epoch with
/// the content keys of both branches inside it
/// (`crate::vault::Vault::seal`), it is the "new head" that
/// `design/causal_encryption.md` §"Multiple Heads" says connects a branch
/// no current member holds a key for.
/// It carries no operations, so it changes nothing a model reads.
pub fn merge_anchor(&mut self) -> Option<NewCommit> {
let _hash = self
.doc
Expand Down Expand Up @@ -352,7 +317,7 @@ impl Document {
};
let head = CommitId::new(change.hash().0);
let _known = self.applied.insert(head);
self.pending = Some(NewCommit {
self.pending.push(NewCommit {
head,
parents: change.deps().iter().map(|h| CommitId::new(h.0)).collect(),
blob: Blob::new(change.raw_bytes().to_vec()),
Expand Down
3 changes: 3 additions & 0 deletions runtime/crates/engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ bincode.workspace = true
ed25519-dalek.workspace = true
future_form.workspace = true
futures.workspace = true
polyvisor-document-history = { path = "../document-history" }
keyhive_core.workspace = true
keyhive_crypto.workspace = true
nonempty.workspace = true
Expand All @@ -35,3 +36,5 @@ subduction_runtime.workspace = true

[dev-dependencies]
futures = { workspace = true, features = ["executor"] }
polyvisor-todo-model = { path = "../todo-model" }
polyvisor-visor-model = { path = "../visor-model" }
Loading
Loading