Live sync KV stores over Iroh

This commit is contained in:
Eric Wendland 2026-05-18 18:36:03 +02:00
commit f85039367c
9 changed files with 473 additions and 13 deletions

View file

@ -109,9 +109,9 @@ Roadmap items should be actionable and checkable:
and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads,
authorized `geth cas fetch`, `geth ssh cert sync`, and
`geth ssh revocation sync` over the Iroh control ALPN, a background SSH
metadata live-sync loop with per-peer cursors in `module_state`, untrusted
discovery-backend trait, custom relay-map config, and Iroh local-network
discovery toggle exist.
metadata and KV live-sync loop with per-peer cursors in `module_state`,
untrusted discovery-backend trait, custom relay-map config, and Iroh
local-network discovery toggle exist.
- Canonical signed-operation envelopes exist for keychain/auth signature
payloads. The keychain reducer builds an active identity view for admin keys,
users, devices, nodes, agents, and endpoint bindings.
@ -138,7 +138,9 @@ Roadmap items should be actionable and checkable:
present. The DB crate and daemon can extract typed read-only `crsql_changes`
batches through `geth db changes` for future sync messages. cr-sqlite loading,
applying remote changes, and sync are still roadmap work.
- KV stores support local SQLite-backed create/set/get. Iroh Documents
- KV stores support local SQLite-backed create/set/get plus authorized
`geth kv sync <node-id> <name>` over Iroh. Background live-sync refreshes
local KV stores from known peers with per-peer/per-KV cursors. Iroh Documents
replication and command-level prefix-capability enforcement are still roadmap
work. The auth evaluator already understands `kv.write_prefix:<prefix>`
grants for `kv.write_key:<key>` requests.

View file

@ -104,7 +104,7 @@ The bootstrap implementation provides:
`geth db changes <name>` for future sync
- local SQLite-backed KV commands: `geth kv create/set/get`; `kv set` accepts
`--subject <principal>` to exercise local capability checks for non-local
callers
callers; `geth kv sync <node-id> <name>` pulls authorized remote updates
- local JSON document commands: `geth document create/status/set/get`
- local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`
- SSH certificate flow metadata:
@ -142,6 +142,10 @@ approval/signing workflows. While the daemon is running, it also performs a
background live-sync tick for known peers every 30 seconds. Live-sync stores
per-peer high-water cursors in local metadata so repeated ticks request only
newer SSH certificate-flow and revocation records.
Named KV stores participate in the same live-sync loop once they exist locally:
manual `geth kv sync <node-id> <name>` and background ticks require `kv.read`
on the remote `resource:kv:<name>` and import only remote entries that are not
older than the local value.
Importing or pinging a peer card never grants capabilities by itself.
When `[iroh].local_discovery = true`, the daemon also advertises and discovers
signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is

View file

@ -304,6 +304,10 @@ pub enum KvCommand {
name: String,
key: String,
},
Sync {
node: String,
name: String,
},
}
#[derive(Debug, Subcommand)]
@ -617,6 +621,7 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
subject,
},
KvCommand::Get { name, key } => ControlRequest::KvGet { name, key },
KvCommand::Sync { node, name } => ControlRequest::KvSync { node, name },
},
Command::Pubsub { command } => match command {
PubsubCommand::Pub { topic, message } => ControlRequest::PubsubPub { topic, message },
@ -1309,6 +1314,27 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("not found");
}
}
ControlResponse::KvSynced {
peer_node_id,
peer_agent_id,
endpoint_id,
name,
entries_imported,
allowed,
reason,
note,
} => {
if allowed {
println!("synced kv {name} from {peer_node_id}: {entries_imported} entries");
} else {
println!("kv sync denied for {name} by {peer_node_id}");
}
println!("agent: {peer_agent_id}");
println!("endpoint: {endpoint_id}");
println!("allowed: {allowed}");
println!("reason: {reason}");
println!("note: {note}");
}
ControlResponse::DocumentCreated { document } => {
println!("created document: {}", document.name);
println!("id: {}", document.id);

View file

@ -4,7 +4,7 @@ use geth_db::{CrSqliteChangeBatch, DbResource};
use geth_discovery::{DiscoveredPeer, PeerCard};
use geth_document::{DocumentResource, DocumentState};
use geth_keychain::KeychainOp;
use geth_kv::{KvEntry, KvResource};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor;
@ -196,6 +196,10 @@ pub enum ControlRequest {
name: String,
key: String,
},
KvSync {
node: String,
name: String,
},
DocumentCreate {
name: String,
},
@ -422,6 +426,16 @@ pub enum ControlResponse {
KvGet {
entry: Option<KvEntry>,
},
KvSynced {
peer_node_id: String,
peer_agent_id: String,
endpoint_id: String,
name: String,
entries_imported: usize,
allowed: bool,
reason: String,
note: String,
},
DocumentCreated {
document: DocumentResource,
},
@ -521,6 +535,12 @@ pub enum PeerControlRequest {
since_ms: i64,
nonce: String,
},
KvSync {
peer_card: PeerCard,
name: String,
since_ms: i64,
nonce: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -589,6 +609,20 @@ pub enum PeerControlResponse {
nonce: String,
note: String,
},
KvSynced {
node_id: String,
agent_id: String,
endpoint_id: String,
remote_endpoint_id: String,
name: String,
entries: Vec<KvSyncEntry>,
high_water_ms: i64,
allowed: bool,
reason: String,
evaluated_ops: usize,
nonce: String,
note: String,
},
Error {
message: String,
},
@ -747,6 +781,30 @@ mod tests {
response
);
let request = ControlRequest::KvSync {
node: "node:peer".to_owned(),
name: "prefs".to_owned(),
};
assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"),
request
);
let response = ControlResponse::KvSynced {
peer_node_id: "node:peer".to_owned(),
peer_agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
name: "prefs".to_owned(),
entries_imported: 1,
allowed: true,
reason: "direct grant".to_owned(),
note: "kv sync".to_owned(),
};
assert_eq!(
decode_response(&encode_response(&response).expect("encode")).expect("decode"),
response
);
let request = ControlRequest::DocumentSet {
name: "notes".to_owned(),
state_json: r#"{"title":"notes"}"#.to_owned(),
@ -908,5 +966,29 @@ mod tests {
.expect("decode"),
response
);
let response = PeerControlResponse::KvSynced {
node_id: "node:peer".to_owned(),
agent_id: "agent:peer".to_owned(),
endpoint_id: "endpoint:peer".to_owned(),
remote_endpoint_id: "endpoint:caller".to_owned(),
name: "prefs".to_owned(),
entries: vec![KvSyncEntry {
key: "apps/foo/theme".to_owned(),
value: "dark".to_owned(),
updated_at_ms: 42,
}],
high_water_ms: 43,
allowed: true,
reason: "direct grant".to_owned(),
evaluated_ops: 1,
nonce: "nonce".to_owned(),
note: "kv sync".to_owned(),
};
assert_eq!(
decode_peer_response(&encode_peer_response(&response).expect("encode"))
.expect("decode"),
response
);
}
}

View file

@ -16,6 +16,13 @@ pub struct KvEntry {
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KvSyncEntry {
pub key: String,
pub value: String,
pub updated_at_ms: i64,
}
#[derive(Debug, thiserror::Error)]
pub enum KvError {
#[error("invalid kv store name: {0}")]

View file

@ -20,7 +20,7 @@ use geth_discovery::{
use geth_document::{DocumentResource, DocumentState};
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_kv::{KvEntry, KvResource};
use geth_kv::{KvEntry, KvResource, KvSyncEntry};
use geth_pipe::{PipeConnection, PipeListener};
use geth_pubsub::PubsubMessage;
use geth_resource::ResourceDescriptor;
@ -259,6 +259,10 @@ pub async fn handle_request_async(
ControlRequest::SshRevocationSync { node: peer_node } => {
ssh_revocation_sync_from_peer(node, &peer_node).await
}
ControlRequest::KvSync {
node: peer_node,
name,
} => kv_sync_from_peer(node, &peer_node, &name).await,
other => handle_request(node, other),
}
}
@ -534,7 +538,8 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result<ControlResponse,
)),
PeerControlResponse::CasFetched { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to ping request".to_owned(),
)),
}
@ -641,7 +646,8 @@ async fn peer_auth_check(
)),
PeerControlResponse::CasFetched { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to auth-check request".to_owned(),
)),
}
@ -777,7 +783,8 @@ async fn cas_fetch_from_peer(
PeerControlResponse::Pong { .. }
| PeerControlResponse::AuthChecked { .. }
| PeerControlResponse::SshCertSynced { .. }
| PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer(
| PeerControlResponse::SshRevocationSynced { .. }
| PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
"peer returned wrong response type to CAS fetch".to_owned(),
)),
}
@ -919,6 +926,90 @@ async fn ssh_revocation_sync_from_peer(
}
}
async fn kv_sync_from_peer(
node: &LocalNode,
peer_node: &str,
name: &str,
) -> Result<ControlResponse, NodeError> {
geth_kv::validate_kv_name(name).map_err(|_| NodeError::InvalidKvName(name.to_owned()))?;
let stream = format!("kv:{name}");
let since_ms =
load_live_sync_cursor(&Store::open(&node.paths.metadata_db())?, peer_node, &stream)?;
let response = request_peer_control(node, peer_node, "kv-sync", |peer_card, nonce| {
PeerControlRequest::KvSync {
peer_card,
name: name.to_owned(),
since_ms,
nonce,
}
})
.await?;
match response {
PeerControlResponse::KvSynced {
node_id,
agent_id,
endpoint_id,
name: response_name,
entries,
high_water_ms,
allowed,
reason,
note,
..
} if response_name == name => {
if !allowed {
return Ok(ControlResponse::KvSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
entries_imported: 0,
allowed,
reason,
note,
});
}
let store = Store::open(&node.paths.metadata_db())?;
let kv = ensure_local_kv_store(&store, name)?;
let mut entries_imported = 0;
for entry in entries {
geth_kv::validate_kv_key(&entry.key)
.map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?;
let should_import = store
.get_kv_entry(&kv.kv_id, &entry.key)?
.is_none_or(|local| entry.updated_at_ms >= local.updated_at_ms);
if should_import {
store.set_kv_entry(&StoredKvEntry {
kv_id: kv.kv_id.clone(),
key: entry.key,
value: entry.value,
updated_at_ms: entry.updated_at_ms,
})?;
entries_imported += 1;
}
}
store_live_sync_cursor(&store, peer_node, &stream, high_water_ms)?;
Ok(ControlResponse::KvSynced {
peer_node_id: node_id,
peer_agent_id: agent_id,
endpoint_id,
name: response_name,
entries_imported,
allowed,
reason,
note,
})
}
PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer(
"peer KV sync response did not match request".to_owned(),
)),
PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)),
_ => Err(NodeError::IrohPeer(
"peer returned wrong response type to KV sync".to_owned(),
)),
}
}
fn live_sync_cursor_key(peer_node: &str, stream: &str) -> String {
format!("live-sync:{peer_node}:{stream}")
}
@ -1011,6 +1102,10 @@ async fn request_peer_control(
| PeerControlResponse::SshRevocationSynced {
nonce: response_nonce,
..
}
| PeerControlResponse::KvSynced {
nonce: response_nonce,
..
} if response_nonce == &nonce => Ok(response),
PeerControlResponse::Error { .. } => Ok(response),
_ => Err(NodeError::IrohPeer(format!(
@ -1058,6 +1153,7 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
return Ok(());
}
let peers = Store::open(&node.paths.metadata_db())?.list_peer_cards()?;
let kv_stores = Store::open(&node.paths.metadata_db())?.list_kv_stores()?;
for peer in peers {
if let Err(error) = ssh_cert_sync_from_peer(node, &peer.peer_id).await {
tracing::debug!(peer = %peer.peer_id, %error, "SSH cert live sync failed");
@ -1065,6 +1161,11 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
if let Err(error) = ssh_revocation_sync_from_peer(node, &peer.peer_id).await {
tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed");
}
for kv in &kv_stores {
if let Err(error) = kv_sync_from_peer(node, &peer.peer_id, &kv.name).await {
tracing::debug!(peer = %peer.peer_id, kv = %kv.name, %error, "KV live sync failed");
}
}
}
Ok(())
}
@ -1334,6 +1435,68 @@ async fn handle_iroh_control_connection(
note: "SSH revocation sync authenticated endpoint/card binding and required ssh_revocation.sync on resource:ssh:revocations".to_owned(),
}
}
PeerControlRequest::KvSync {
peer_card,
name,
since_ms,
nonce,
} => {
geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate(
peer_card.clone(),
UnixMillis(geth_store::now_ms()),
DiscoverySource::PeerExchange,
)?;
let store = Store::open(&node.paths.metadata_db())?;
store.upsert_peer_card(&StoredPeerCard {
peer_id: peer_card.node_id.to_string(),
card_json: serde_json::to_string(&peer_card)?,
updated_at_ms: discovered.discovered_at.0,
})?;
if let Some(kv) = store.get_kv_store_by_name(&name)? {
let capability = "kv.read".to_owned();
let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops(
&load_auth_ops_for_resource(&store, &kv.resource_id)?,
PrincipalId::new(peer_card.node_id.to_string()),
ResourceId::new(kv.resource_id.clone()),
Capability::new(capability),
);
let entries = if explanation.allowed {
store
.list_kv_entries_since(&kv.kv_id, since_ms)?
.into_iter()
.map(|entry| KvSyncEntry {
key: entry.key,
value: entry.value,
updated_at_ms: entry.updated_at_ms,
})
.collect()
} else {
Vec::new()
};
PeerControlResponse::KvSynced {
node_id: node.node_id.clone(),
agent_id: node.agent_id.clone(),
endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(),
remote_endpoint_id,
name,
entries,
high_water_ms,
allowed: explanation.allowed,
reason: explanation.reason,
evaluated_ops: explanation.evaluated_ops,
nonce,
note: "KV sync authenticated endpoint/card binding and required kv.read on the remote KV resource".to_owned(),
}
} else {
PeerControlResponse::Error {
message: format!("kv store not found: {name}"),
}
}
}
};
send.write_all(geth_control::encode_peer_response(&response)?.as_bytes())
.await
@ -1472,6 +1635,7 @@ pub fn handle_request(
ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::KvSync { .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::ResourceList => Ok(ControlResponse::ResourceList {
resources: store
.list_resources()?
@ -2482,6 +2646,19 @@ fn kv_entry_from_stored(stored: StoredKvEntry) -> KvEntry {
}
}
fn ensure_local_kv_store(store: &Store, name: &str) -> Result<StoredKvStore, NodeError> {
if let Some(kv) = store.get_kv_store_by_name(name)? {
return Ok(kv);
}
let stored = StoredKvStore {
kv_id: format!("kv:{name}"),
resource_id: format!("resource:kv:{name}"),
name: name.to_owned(),
};
store.insert_kv_store(&stored)?;
Ok(stored)
}
fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentResource {
DocumentResource {
id: stored.document_id.clone().into(),
@ -2985,6 +3162,23 @@ mod tests {
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
other => panic!("unexpected SSH revocation response: {other:?}"),
};
handle_request(
&right,
ControlRequest::KvCreate {
name: "prefs".to_owned(),
},
)
.expect("right kv create");
handle_request(
&right,
ControlRequest::KvSet {
name: "prefs".to_owned(),
key: "apps/foo/theme".to_owned(),
value: "dark".to_owned(),
subject: None,
},
)
.expect("right kv set");
let ping = handle_request_async(
&left,
@ -3063,6 +3257,29 @@ mod tests {
other => panic!("unexpected denied CAS fetch response: {other:?}"),
}
let denied_kv_sync = handle_request_async(
&left,
ControlRequest::KvSync {
node: right_card.node_id.to_string(),
name: "prefs".to_owned(),
},
)
.await
.expect("denied kv sync");
match denied_kv_sync {
ControlResponse::KvSynced {
allowed,
entries_imported,
reason,
..
} => {
assert!(!allowed);
assert_eq!(entries_imported, 0);
assert!(reason.contains("no active direct or group grant"));
}
other => panic!("unexpected denied KV sync response: {other:?}"),
}
handle_request(
&right,
ControlRequest::AuthGrant {
@ -3073,6 +3290,16 @@ mod tests {
},
)
.expect("grant left peer");
handle_request(
&right,
ControlRequest::AuthGrant {
subject: left.node_id.clone(),
resource: "resource:kv:prefs".to_owned(),
capability: "kv.read".to_owned(),
grant_id: Some("grant:left-kv-read".to_owned()),
},
)
.expect("grant left kv read");
let allowed = handle_request_async(
&left,
@ -3131,6 +3358,45 @@ mod tests {
other => panic!("unexpected allowed CAS fetch response: {other:?}"),
}
let kv_synced = handle_request_async(
&left,
ControlRequest::KvSync {
node: right_card.node_id.to_string(),
name: "prefs".to_owned(),
},
)
.await
.expect("allowed kv sync");
match kv_synced {
ControlResponse::KvSynced {
allowed,
entries_imported,
reason,
note,
..
} => {
assert!(allowed);
assert_eq!(entries_imported, 1);
assert!(reason.contains("direct grant"));
assert!(note.contains("kv.read"));
}
other => panic!("unexpected allowed KV sync response: {other:?}"),
}
let synced_theme = handle_request(
&left,
ControlRequest::KvGet {
name: "prefs".to_owned(),
key: "apps/foo/theme".to_owned(),
},
)
.expect("left kv get synced");
match synced_theme {
ControlResponse::KvGet { entry: Some(entry) } => {
assert_eq!(entry.value, "dark");
}
other => panic!("unexpected synced KV get response: {other:?}"),
}
let denied_cert_sync = handle_request_async(
&left,
ControlRequest::SshCertSync {
@ -3280,6 +3546,16 @@ mod tests {
ControlResponse::SshRevocationAdded { revocation } => revocation.id,
other => panic!("unexpected second SSH revocation response: {other:?}"),
};
handle_request(
&right,
ControlRequest::KvSet {
name: "prefs".to_owned(),
key: "apps/foo/live".to_owned(),
value: "synced".to_owned(),
subject: None,
},
)
.expect("right second kv set");
run_live_sync_once(&left)
.await
@ -3299,6 +3575,13 @@ mod tests {
.iter()
.any(|revocation| revocation.revocation_id == second_revocation_id.as_str())
);
assert_eq!(
left_store
.get_kv_entry("kv:prefs", "apps/foo/live")
.expect("get live-synced kv")
.map(|entry| entry.value),
Some("synced".to_owned())
);
left_endpoint.shutdown().await;
right_endpoint.shutdown().await;

View file

@ -335,6 +335,21 @@ impl Store {
}
}
pub fn list_kv_stores(&self) -> Result<Vec<StoredKvStore>, StoreError> {
let mut stmt = self
.conn
.prepare("SELECT kv_id, resource_id, name FROM kv_stores ORDER BY name")?;
let rows = stmt.query_map([], |row| {
Ok(StoredKvStore {
kv_id: row.get(0)?,
resource_id: row.get(1)?,
name: row.get(2)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn set_kv_entry(&self, entry: &StoredKvEntry) -> Result<(), StoreError> {
self.conn.execute(
r#"INSERT OR REPLACE INTO kv_entries(kv_id, key, value, updated_at_ms)
@ -366,6 +381,29 @@ impl Store {
}
}
pub fn list_kv_entries_since(
&self,
kv_id: &str,
since_ms: i64,
) -> Result<Vec<StoredKvEntry>, StoreError> {
let mut stmt = self.conn.prepare(
r#"SELECT kv_id, key, value, updated_at_ms
FROM kv_entries
WHERE kv_id = ?1 AND updated_at_ms >= ?2
ORDER BY updated_at_ms, key"#,
)?;
let rows = stmt.query_map(params![kv_id, since_ms], |row| {
Ok(StoredKvEntry {
kv_id: row.get(0)?,
key: row.get(1)?,
value: row.get(2)?,
updated_at_ms: row.get(3)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(StoreError::from)
}
pub fn insert_document_resource(
&self,
document: &StoredDocumentResource,
@ -1518,6 +1556,7 @@ mod tests {
store.get_kv_store_by_name("prefs").expect("get kv"),
Some(kv.clone())
);
assert_eq!(store.list_kv_stores().expect("list kv"), vec![kv.clone()]);
let entry = StoredKvEntry {
kv_id: kv.kv_id.clone(),
@ -1530,7 +1569,13 @@ mod tests {
store
.get_kv_entry("kv:prefs", "apps/foo/theme")
.expect("get entry"),
Some(entry)
Some(entry.clone())
);
assert_eq!(
store
.list_kv_entries_since("kv:prefs", 1)
.expect("list entries since"),
vec![entry]
);
}

View file

@ -141,8 +141,14 @@ are future work.
through `kv create/set/get`. `kv set --subject <principal>` evaluates local auth
ops for `kv.write_key:<key>` so prefix grants can be tested before networked
callers exist. The local node/agent retains owner access for administration.
Iroh Documents namespaces, remote caller identity, and replication are future
work.
Iroh Documents namespaces remain the target backend, but the bootstrap can sync
named KV stores over the protected Iroh control ALPN. `geth kv sync <node-id>
<name>` requires `kv.read` on the remote `resource:kv:<name>`, transfers entries
at or beyond a per-peer/per-KV high-water cursor, and imports only values that
are at least as new as the local entry timestamp. The daemon background
live-sync loop runs the same KV sync for local KV stores and known peers.
Private value encryption should use resource secret epochs before payloads are
exposed to remote peers.
`geth-document` currently registers local document resources and stores
validated JSON state in the local SQLite metadata store through

View file

@ -264,6 +264,11 @@ authorization and durable-state boundaries clear.
- `[x]` Tests cover allowed and denied prefix-scoped KV write explanations.
- `[x]` `geth kv set --subject <principal>` enforces local capability
decisions for non-local test callers.
- `[x]` `geth kv sync <node-id> <name>` pulls authorized updates from an
imported peer over Iroh.
- `[x]` Remote KV sync requires `kv.read` on `resource:kv:<name>`.
- `[x]` Background live-sync refreshes local KV stores from known peers using
per-peer/per-KV high-water cursors.
- `[ ]` KV metadata is replicated through Iroh Documents.
- `[~]` Iroh-gossip pubsub integration.