diff --git a/crates/memoryd/src/honcho/mirror.rs b/crates/memoryd/src/honcho/mirror.rs index a593ddf..2fda6fc 100644 --- a/crates/memoryd/src/honcho/mirror.rs +++ b/crates/memoryd/src/honcho/mirror.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; -use bxcore::{SessionId, Timestamp}; +use bxcore::{DataClass, SessionId, Timestamp}; use crate::files::Store; use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace}; @@ -18,6 +18,9 @@ pub const OWNER: &str = "owner"; /// The part of an edit that has no Boxmaker session to stamp its conclusion with. pub const NO_SESSION: &str = "the edit has no Boxmaker session"; +/// Why a write from a `secret` session sends nothing but its deletions (M5c spec, section 3). +pub const SECRET_WRITE: &str = "the write is from a secret session"; + /// `before` and `after` as multisets of entry text: what `before` has more of (dropped, in /// `before`'s order), and what `after` has more of (added, in `after`'s order). pub fn diff(before: &[String], after: &[String]) -> (Vec, Vec) { @@ -138,8 +141,25 @@ fn note_actions( .collect() } +/// Whether `record` came from a `secret` session: by the class the journal gives the call, or, for +/// a line from before it did, by the class any of its notes was written with. +fn is_secret(record: &journal::Record, notes: &[notes::Note]) -> bool { + record.class.as_deref() == Some(notes::class_name(DataClass::Secret)) + || notes.iter().any(|n| { + matches!( + n.kind, + Kind::Noted { + class: DataClass::Secret, + .. + } + ) + }) +} + /// What `record` asks of Honcho, in the order to send it: an identity edit's, or a write saved as -/// notes. Anything else (a rejection) asks nothing. +/// notes. Anything else (a rejection) asks nothing. From a `secret` session only the deletions +/// go, in their order, and everything else the write asked is one `Skip` after them: deleting +/// leaks nothing and keeps Honcho consistent with the files. pub fn actions( record: &journal::Record, session: Option<&SessionId>, @@ -147,10 +167,22 @@ pub fn actions( owner: &str, ) -> Vec { let notes = notes_of(record); - match &record.file { + let all = match &record.file { Some(file) => identity_actions(record, file, ¬es, session, agent, owner), None => note_actions(record, ¬es, session, agent), + }; + if !is_secret(record, ¬es) { + return all; } + let (mut kept, held): (Vec, Vec) = all + .into_iter() + .partition(|a| matches!(a, Action::Unconclude { .. })); + if !held.is_empty() { + kept.push(Action::Skip { + why: SECRET_WRITE.to_string(), + }); + } + kept } /// With `[honcho]`: decides each journalled edit into the ledger. diff --git a/crates/memoryd/src/journal.rs b/crates/memoryd/src/journal.rs index 66b3acb..feee41b 100644 --- a/crates/memoryd/src/journal.rs +++ b/crates/memoryd/src/journal.rs @@ -35,6 +35,10 @@ pub struct Record { /// For an undo, the edit it reverted. #[serde(default, skip_serializing_if = "Option::is_none")] pub undoes: Option, + /// The call's class (`public`, `private` or `secret`, as on the wire): what its session had + /// read. `None` for the owner's undo and for lines from before it was recorded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class: Option, } fn file_of(name: &str) -> Option { @@ -132,6 +136,7 @@ pub fn record(store: &Store, ctx: &Context, outcome: &Outcome) -> Result Result { untrusted: false, flag: None, undoes: Some(id.to_string()), + class: None, }, now, )?; diff --git a/crates/memoryd/src/notes.rs b/crates/memoryd/src/notes.rs index 7b90bdd..f264a79 100644 --- a/crates/memoryd/src/notes.rs +++ b/crates/memoryd/src/notes.rs @@ -37,7 +37,8 @@ pub fn minute(time: Timestamp) -> String { format!("{}Z", s.get(..16).unwrap_or(&s)) } -fn class_name(class: DataClass) -> &'static str { +/// A class as the notes and the journal spell it, the same as on the wire. +pub fn class_name(class: DataClass) -> &'static str { match class { DataClass::Public => "public", DataClass::Private => "private", diff --git a/crates/memoryd/src/review.rs b/crates/memoryd/src/review.rs index fa26de5..9aafa95 100644 --- a/crates/memoryd/src/review.rs +++ b/crates/memoryd/src/review.rs @@ -246,6 +246,7 @@ pub fn reject(store: &Store, id: &str, now: Timestamp) -> Result untrusted: false, flag: None, undoes: Some(id.to_string()), + class: None, }, now, )?; diff --git a/crates/memoryd/tests/honcho_mirror.rs b/crates/memoryd/tests/honcho_mirror.rs index 9821b27..d2cdcce 100644 --- a/crates/memoryd/tests/honcho_mirror.rs +++ b/crates/memoryd/tests/honcho_mirror.rs @@ -57,6 +57,7 @@ fn record(id: &str, action: &str, file: Option<&str>, before: &[&str], after: &[ untrusted: false, flag: None, undoes: None, + class: None, } } diff --git a/crates/memoryd/tests/honcho_mirror_secret.rs b/crates/memoryd/tests/honcho_mirror_secret.rs new file mode 100644 index 0000000..ac30b66 --- /dev/null +++ b/crates/memoryd/tests/honcho_mirror_secret.rs @@ -0,0 +1,192 @@ +//! Memory writes from a `secret` session are never written to Honcho (M5c spec, section 3; review +//! of M5c-1, finding 1): the journal records the call's class, and the mirror turns what such a +//! write would conclude or send to the fleet into one `Skip`, while its deletions still go. + +#[path = "support/tmp.rs"] +mod tmp; + +use bxcore::{DataClass, MemoryArgs, SessionId, Timestamp}; +use memoryd::edit::{Context, apply}; +use memoryd::files::Store; +use memoryd::honcho::ledger::{Action, Workspace}; +use memoryd::honcho::mirror::{SECRET_WRITE, actions}; +use memoryd::journal::{self, Record}; +use memoryd::notes::{Kind, Note}; +use tmp::TempDir; + +const AGENT: &str = "boxmaker0"; +const OWNER_PEER: &str = "kyle"; + +fn v(texts: &[&str]) -> Vec { + texts.iter().map(|t| t.to_string()).collect() +} + +fn sid(s: &str) -> SessionId { + SessionId::new(s).unwrap() +} + +fn noted(class: DataClass, untrusted: bool, text: &str) -> String { + Note { + kind: Kind::Noted { class, untrusted }, + time: "2026-09-28T10:00Z".to_string(), + session: "mm-a".to_string(), + text: text.to_string(), + } + .render() +} + +fn record(file: Option<&str>, before: &[&str], after: &[&str], class: Option<&str>) -> Record { + Record { + id: "e1".to_string(), + time: "2026-09-28T10:00:00.000Z".to_string(), + session: "mm-a".to_string(), + call: 1, + action: "add".to_string(), + file: file.map(str::to_string), + to: file.unwrap_or("notes.md").to_string(), + before: v(before), + after: v(after), + notes: Vec::new(), + untrusted: false, + flag: None, + undoes: None, + class: class.map(str::to_string), + } +} + +fn of(record: &Record) -> Vec { + actions(record, Some(&sid("mm-a")), AGENT, OWNER_PEER) +} + +fn skip() -> Vec { + vec![Action::Skip { + why: SECRET_WRITE.to_string(), + }] +} + +#[test] +fn a_secret_sessions_notes_are_skipped() { + let mut clean = record(None, &[], &[], Some("secret")); + clean.action = "note".to_string(); + clean.notes = vec![ + noted(DataClass::Secret, false, "The vault code is 4411."), + noted(DataClass::Secret, false, "And a second one."), + ]; + assert_eq!(of(&clean), skip(), "no conclusion, one Skip for the record"); + + let mut untrusted = clean.clone(); + untrusted.untrusted = true; + untrusted.notes = vec![noted(DataClass::Secret, true, "The page says 3.2.")]; + assert_eq!(of(&untrusted), skip(), "no fleet message"); +} + +#[test] +fn a_note_marked_secret_is_skipped_without_the_records_class() { + // A line from before the journal had a class: the note's own header still says secret. + let mut old = record(None, &[], &[], None); + old.notes = vec![noted(DataClass::Secret, false, "A secret fact.")]; + assert_eq!(of(&old), skip()); +} + +#[test] +fn a_secret_identity_add_is_skipped() { + let add = record( + Some("user.md"), + &["x"], + &["x", "Kyle's PIN is 1234."], + Some("secret"), + ); + assert_eq!(of(&add), skip()); +} + +#[test] +fn a_secret_identity_remove_still_deletes() { + let remove = record(Some("memory.md"), &["x", "gone"], &["x"], Some("secret")); + assert_eq!( + of(&remove), + vec![Action::Unconclude { + workspace: Workspace::Trusted, + peer: AGENT.to_string(), + text: "gone".to_string(), + }] + ); + // A replace: the deletion first, then one Skip in place of the conclusion. + let replace = record(Some("user.md"), &["old"], &["new"], Some("secret")); + assert_eq!( + of(&replace), + vec![ + Action::Unconclude { + workspace: Workspace::Trusted, + peer: OWNER_PEER.to_string(), + text: "old".to_string(), + }, + Action::Skip { + why: SECRET_WRITE.to_string() + } + ] + ); +} + +#[test] +fn public_and_private_writes_are_unchanged() { + for class in ["public", "private"] { + let add = record(Some("user.md"), &[], &["Prefers UTC."], Some(class)); + assert_eq!( + of(&add), + vec![Action::Conclude { + workspace: Workspace::Trusted, + peer: OWNER_PEER.to_string(), + session: sid("mm-a"), + text: "Prefers UTC.".to_string(), + }], + "{class}" + ); + } +} + +#[test] +fn old_journal_lines_without_a_class_still_read() { + let line = r#"{"id":"2026-09-24-0001","time":"2026-09-24T20:14:37.123Z","session":"mm-s1","call":7,"action":"add","file":"memory.md","to":"memory.md","before":[],"after":["a"],"notes":[],"untrusted":false}"#; + let record: Record = serde_json::from_str(line).unwrap(); + assert_eq!(record.class, None); + assert!(!serde_json::to_string(&record).unwrap().contains("class")); +} + +#[test] +fn the_journal_records_the_calls_class() { + let dir = TempDir::new("mirror-secret-journal"); + let starter = dir.write("etc/soul.md", "Soul.\n"); + let store = Store::new(&dir.path().join("memory")); + store.init(&starter).unwrap(); + for (taint, name) in [ + (DataClass::Public, "public"), + (DataClass::Private, "private"), + (DataClass::Secret, "secret"), + ] { + let ctx = Context { + session: "mm-s1".to_string(), + call: 7, + taint, + untrusted: false, + now: Timestamp::parse("2026-09-24T20:14:37.123Z").unwrap(), + }; + let args: MemoryArgs = serde_json::from_str(&format!( + r#"{{"action":"add","file":"memory","text":"from a {name} session"}}"# + )) + .unwrap(); + let outcome = apply(&store, &args, &ctx).unwrap(); + journal::record(&store, &ctx, &outcome).unwrap(); + let last = journal::records(&store).unwrap().pop().unwrap(); + assert_eq!(last.class.as_deref(), Some(name)); + } + // An owner's undo has no call, so no class. + let last = journal::records(&store).unwrap().pop().unwrap(); + journal::undo( + &store, + &last.id, + Timestamp::parse("2026-09-24T20:15:00.000Z").unwrap(), + ) + .unwrap(); + let undo = journal::records(&store).unwrap().pop().unwrap(); + assert_eq!((undo.action.as_str(), undo.class), ("undo", None)); +}