diff --git a/crates/cyber-core/src/binary/draft.rs b/crates/cyber-core/src/binary/draft.rs index 9022baf6..98193492 100644 --- a/crates/cyber-core/src/binary/draft.rs +++ b/crates/cyber-core/src/binary/draft.rs @@ -402,7 +402,7 @@ impl Draft { Ok(format!("{:x}", hash.finalize())) } - fn ranges(&self) -> Vec<(u64, u64, u64, u64)> { + pub(super) fn ranges(&self) -> Vec<(u64, u64, u64, u64)> { let (mut original, mut after, mut start) = (0, 0, 0); let mut ranges = Vec::new(); for piece in &self.pieces { @@ -452,7 +452,12 @@ impl Draft { proof.update(&proposed_sha256); let (mut original_bytes, mut proposed_bytes, mut count) = (0, 0, 0); let mut ranges = Vec::new(); - for (before, removed, after, inserted) in self.ranges() { + let candidates = if proposed_sha256 == self.base.sha256() { + vec![] + } else { + self.ranges() + }; + for (before, removed, after, inserted) in candidates { let before_sha256 = self.range_hash(true, before, removed, &cancelled)?; let after_sha256 = self.range_hash(false, after, inserted, &cancelled)?; if removed == inserted && before_sha256 == after_sha256 { diff --git a/crates/cyber-core/src/binary/editor.rs b/crates/cyber-core/src/binary/editor.rs index 397af716..d6267b95 100644 --- a/crates/cyber-core/src/binary/editor.rs +++ b/crates/cyber-core/src/binary/editor.rs @@ -19,7 +19,10 @@ use std::path::PathBuf; mod catalog; #[path = "editor/restore.rs"] mod restore; +#[path = "editor/versions.rs"] +mod versions; pub use catalog::{StoredDocument, StoredDocuments}; +pub use versions::VersionView; const MAX_OPERATIONS: usize = 4096; const EFFECTS: &str = "Permissions, executable bits, owner/group, supported extended attributes and ACL are preserved. Native replacement changes file identity and updates modification time. Save checkpoints draft undo/redo; Restore Last Save uses the retained preimage. External checksums, signatures and irreversible observer effects are not repaired. Uncooperative writers can leave an unknown outcome; evidence is retained."; @@ -66,6 +69,8 @@ struct LastSave { preimage: BaseRef, installed_sha256: String, operation: String, + #[serde(default)] + evidence: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -76,6 +81,8 @@ struct Intent { new_base: BaseRef, after_draft: DraftState, stage: String, + #[serde(default)] + evidence: Option, } #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -428,6 +435,7 @@ impl Editor { return Ok(SavePreparation::Existing(prior)); } self.available()?; + self.publish_last_version()?; let current = self.review(0, 128, &cancelled)?; if current.fingerprint != review { return Err(Error::Changed); @@ -489,6 +497,14 @@ impl Editor { reason: None, notification_pending: false, }; + let evidence = versions::Evidence::prepared( + self, + &operation, + ¤t.draft, + after.clone(), + &after_draft, + self.draft.ranges(), + )?; let intent = Intent { operation, prior: target, @@ -496,6 +512,7 @@ impl Editor { new_base: BaseRef::of(after_draft.base()), after_draft: after_draft.snapshot(), stage: stage_name, + evidence: Some(evidence), }; Ok(SavePreparation::Ready(Box::new(PreparedSave { intent, @@ -538,6 +555,19 @@ impl Editor { &mut self, prepared: PreparedSave, cancelled: impl Fn() -> bool, + ) -> Result { + self.commit_save_checked(prepared, cancelled, || Ok(())) + } + /// The host rechecks trusted current read/edit/Mode and ancestor authority + /// after content verification and capacity reservation, before native intent. + /// The check must not prompt, invoke hooks/models, or release mutation owners. + /// A refusal has no native effect and leaves the operation eligible for an + /// explicit newly authorized attempt. It does not manufacture a save receipt. + pub fn commit_save_checked( + &mut self, + prepared: PreparedSave, + cancelled: impl Fn() -> bool, + authorize: impl FnOnce() -> Result<(), Error>, ) -> Result { if let Some(prior) = self.prior( &prepared.intent.operation.id, @@ -609,6 +639,10 @@ impl Editor { .0 .saturating_sub(prepared.intent.after.length.0), )?; + authorize()?; + if cancelled() { + return Err(Error::Cancelled); + } self.record.pending = Some(prepared.intent); persist(&mut self.store, &self.record)?; let intent = self.record.pending.as_ref().ok_or(Error::Invalid)?; @@ -686,6 +720,11 @@ impl Editor { operation.file_effect = FileEffect::Changed; operation.reason = None; operation.notification_pending = true; + let mut evidence = intent.evidence.clone(); + if let Some(evidence) = &mut evidence { + evidence.target = target.clone(); + evidence.operation = operation.clone(); + } let record = Record { version: 1, owner: self.record.owner.clone(), @@ -704,6 +743,7 @@ impl Editor { preimage: self.record.base.clone(), installed_sha256: intent.new_base.sha256.clone(), operation: operation.id.clone(), + evidence, }), }; (record, operation) @@ -782,6 +822,54 @@ mod tests { } } #[test] + fn final_save_authority_refusal_preserves_source_draft_and_receipt_absence() { + let (_root, mut editor, path, _owner) = setup(); + patch(&mut editor, "patch_one"); + let revision = editor.draft().revision(); + let prepared = ready(&mut editor, "save_checked"); + let called = std::cell::Cell::new(false); + let result = editor.commit_save_checked( + *prepared, + || false, + || { + called.set(true); + Err(Error::Changed) + }, + ); + assert!(called.get()); + assert!(matches!(result, Err(Error::Changed))); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + assert_eq!(editor.draft().revision(), revision); + assert_eq!(editor.draft().read(0, 4).unwrap(), [0, 255, 128, 27]); + assert!(!editor.unresolved()); + assert!(editor.lookup_operation("save_checked").unwrap().is_none()); + // A stricter check can also observe Stop while checking authority. + let cancelled = std::cell::Cell::new(false); + let prepared = ready(&mut editor, "save_checked"); + assert!(matches!( + editor.commit_save_checked( + *prepared, + || cancelled.get(), + || { + cancelled.set(true); + Ok(()) + } + ), + Err(Error::Cancelled) + )); + assert_eq!(std::fs::read(&path).unwrap(), b"original"); + assert!(!editor.unresolved()); + let prepared = ready(&mut editor, "save_checked"); + assert_eq!( + editor + .commit_save_checked(*prepared, || false, || Ok(())) + .unwrap() + .outcome, + Outcome::Acknowledged + ); + assert_eq!(std::fs::read(path).unwrap(), [0, 255, 128, 27]); + } + #[test] fn prepared_editors_keep_native_source_and_directory_pins_after_the_live_view_is_dropped() { use std::os::unix::fs::MetadataExt; let (_root, editor, _path, owner) = setup(); diff --git a/crates/cyber-core/src/binary/editor/restore.rs b/crates/cyber-core/src/binary/editor/restore.rs index 3af91fa8..8a7f694c 100644 --- a/crates/cyber-core/src/binary/editor/restore.rs +++ b/crates/cyber-core/src/binary/editor/restore.rs @@ -82,6 +82,7 @@ impl Editor { return Ok(SavePreparation::Existing(prior)); } self.available()?; + self.publish_last_version()?; let current = self.restore_review(&cancelled)?; if current.fingerprint != review { return Err(Error::Changed); @@ -130,6 +131,24 @@ impl Editor { reason: None, notification_pending: false, }; + let ranges = if current.draft.range_count == 0 { + vec![] + } else { + vec![( + 0, + current.draft.base_length.0, + 0, + current.draft.proposed_length.0, + )] + }; + let evidence = versions::Evidence::prepared( + self, + &operation, + ¤t.draft, + after.clone(), + &after_draft, + ranges, + )?; let intent = Intent { operation, prior: target, @@ -137,6 +156,7 @@ impl Editor { new_base: BaseRef::of(after_draft.base()), after_draft: after_draft.snapshot(), stage: stage_name, + evidence: Some(evidence), }; Ok(SavePreparation::Ready(Box::new(PreparedSave { intent, diff --git a/crates/cyber-core/src/binary/editor/versions.rs b/crates/cyber-core/src/binary/editor/versions.rs new file mode 100644 index 00000000..a02cb199 --- /dev/null +++ b/crates/cyber-core/src/binary/editor/versions.rs @@ -0,0 +1,732 @@ +//! Immutable acknowledged source versions. Mappings contain offsets only: cancelled +//! or superseded draft commands never enter the shared saved-source namespace. +use super::*; +use crate::binary::{draft::Review, receipts::Version}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct Evidence { + version: u32, + pub info: Version, + pub operation: Operation, + owner: Owner, + before: BaseRef, + after: BaseRef, + pub target: Target, + revision: String, + ranges: Vec<(u64, u64, u64, u64)>, +} +impl Evidence { + pub fn prepared( + editor: &Editor, + operation: &Operation, + review: &Review, + target: Target, + after: &Draft, + ranges: Vec<(u64, u64, u64, u64)>, + ) -> Result { + if ranges.len() > super::super::draft::MAX_HISTORY * super::super::draft::MAX_SPLICES { + return Err(Error::Limit); + } + let document = editor.store.document_id(); + let version_id = format!( + "ver_{}", + signature(&( + "binary-version-v1", + document, + &operation.id, + &operation.payload_sha256 + ))? + ); + Ok(Self { + version: 1, + info: Version { + version_id, + document_id: document.into(), + operation_id: operation.id.clone(), + kind: operation.kind.clone(), + path: crate::vcs::slash_path(&target.relative), + before_sha256: review.base_sha256.clone(), + after_sha256: review.proposed_sha256.clone(), + before_length: review.base_length, + after_length: review.proposed_length, + original_bytes: review.original_bytes, + proposed_bytes: review.proposed_bytes, + range_count: review.range_count, + created_ms: chrono::Utc::now().timestamp_millis(), + }, + operation: operation.clone(), + owner: editor.record.owner.clone(), + before: editor.record.base.clone(), + after: BaseRef::of(after.base()), + target, + revision: review.revision.clone(), + ranges, + }) + } + fn validate_common(&self, document: &str) -> Result<(), Error> { + let operation = &self.operation; + let expected = format!( + "ver_{}", + signature(&( + "binary-version-v1", + document, + &operation.id, + &operation.payload_sha256 + ))? + ); + if self.version != 1 + || self.info.version_id != expected + || self.info.document_id != document + || operation.outcome != Outcome::Acknowledged + || operation.file_effect != FileEffect::Changed + || operation.id != self.info.operation_id + || self.info.kind != operation.kind + || self.target.root != self.owner.root + || self.info.path != crate::vcs::slash_path(&self.target.relative) + || self.target.relative.as_os_str().is_empty() + || self + .target + .relative + .components() + .any(|part| !matches!(part, std::path::Component::Normal(_))) + { + return Err(Error::Changed); + } + if self.info.before_sha256 != self.before.sha256 + || self.info.after_sha256 != self.after.sha256 + || self.info.before_length != self.before.length + || self.info.after_length != self.after.length + || !self.before.name.starts_with("base_") + || !self.after.name.starts_with("base_") + || self.info.range_count > self.ranges.len() + || self.ranges.len() + > crate::binary::draft::MAX_HISTORY * crate::binary::draft::MAX_SPLICES + { + return Err(Error::Changed); + } + let (mut before, mut after) = (0, 0); + for &(at, removed, to, inserted) in &self.ranges { + if at < before || to < after || at - before != to - after { + return Err(Error::Invalid); + } + before = at + .checked_add(removed) + .filter(|end| *end <= self.before.length.0) + .ok_or(Error::Invalid)?; + after = to + .checked_add(inserted) + .filter(|end| *end <= self.after.length.0) + .ok_or(Error::Invalid)?; + } + if self.before.length.0 - before != self.after.length.0 - after { + return Err(Error::Invalid); + } + Ok(()) + } + fn validate(&self, editor: &Editor, operation: &Operation) -> Result<(), Error> { + self.validate_common(editor.store.document_id())?; + if self.owner != editor.record.owner + || self.operation.id != operation.id + || self.operation.payload_sha256 != operation.payload_sha256 + || self.after.name != editor.record.base.name + { + return Err(Error::Changed); + } + Ok(()) + } +} +impl Editor { + /// Publish only after native acknowledgement is durable. The current journal + /// retains the evidence if publication fails; the next save cannot discard it. + pub fn publish_last_version(&mut self) -> Result, Error> { + self.store.validate()?; + let Some(last) = &self.record.last_save else { + return Ok(None); + }; + let Some(evidence) = &last.evidence else { + return Ok(None); + }; + let operation = self + .record + .operations + .iter() + .find(|operation| operation.id == last.operation) + .ok_or(Error::Invalid)?; + if operation.outcome != Outcome::Acknowledged + || operation.file_effect != FileEffect::Changed + || evidence.info.operation_id != operation.id + || evidence.after.sha256 != last.installed_sha256 + { + return Err(Error::Changed); + } + evidence.validate(self, operation)?; + let name = format!("{}.json", evidence.info.version_id); + match self.store.directory().symlink_metadata(&name) { + Ok(_) => { + let prior = self.store.read_record(&name)?; + if prior != serde_json::to_vec(evidence).map_err(|_| Error::Invalid)? { + return Err(Error::Changed); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + self.store + .record(&name, encoded_size(evidence)?, |writer| { + serde_json::to_writer(writer, evidence).map_err(|_| Error::Io) + })?; + } + Err(_) => return Err(Error::Io), + } + Ok(Some(evidence.info.clone())) + } +} + +/// Read-only saved-source view. This type cannot mutate or access draft history. +pub struct VersionView { + evidence: Evidence, + before: Base, + after: Base, +} +impl VersionView { + pub fn info(&self) -> &Version { + &self.evidence.info + } + pub fn page( + &self, + before: bool, + offset: Offset, + limit: usize, + ) -> Result { + let base = if before { &self.before } else { &self.after }; + if limit == 0 || limit > super::super::MAX_PAGE || offset.0 > base.length() { + return Err(Error::Limit); + } + let bytes = base.read( + offset.0, + (base.length() - offset.0).min(limit as u64) as usize, + )?; + Ok(super::super::Page { + offset, + eof: offset.0 + bytes.len() as u64 == base.length(), + sha256: format!("{:x}", Sha256::digest(&bytes)), + bytes, + }) + } + pub fn review( + &self, + start: usize, + limit: usize, + cancelled: impl Fn() -> bool, + ) -> Result { + if limit == 0 || limit > 128 { + return Err(Error::Limit); + } + let mut ranges = vec![]; + let (mut count, mut removed_total, mut inserted_total) = (0, 0, 0); + for &(at, removed, to, inserted) in &self.evidence.ranges { + let before = digest_range(&self.before, at, removed, &cancelled)?; + let after = digest_range(&self.after, to, inserted, &cancelled)?; + if removed == inserted && before == after { + continue; + } + if count >= start && ranges.len() < limit { + ranges.push(crate::binary::draft::ChangeRange { + original_offset: Offset(at), + original_length: Offset(removed), + draft_offset: Offset(to), + draft_length: Offset(inserted), + before_sha256: before, + after_sha256: after, + before_hex: crate::binary::draft::encode_hex( + &self.before.read(at, removed.min(256) as usize)?, + ), + after_hex: crate::binary::draft::encode_hex( + &self.after.read(to, inserted.min(256) as usize)?, + ), + preview_truncated: removed > 256 || inserted > 256, + }); + } + count += 1; + removed_total += removed; + inserted_total += inserted; + } + let info = self.info(); + if start > count + || count != info.range_count + || removed_total != info.original_bytes.0 + || inserted_total != info.proposed_bytes.0 + { + return Err(Error::Changed); + } + Ok(Review { + revision: self.evidence.revision.clone(), + base_sha256: info.before_sha256.clone(), + proposed_sha256: info.after_sha256.clone(), + base_length: info.before_length, + proposed_length: info.after_length, + original_bytes: Offset(removed_total), + proposed_bytes: Offset(inserted_total), + range_count: count, + next_range: (start + ranges.len() < count).then_some(start + ranges.len()), + ranges, + fingerprint: signature(&self.evidence)?, + }) + } +} +fn digest_range( + base: &Base, + at: u64, + length: u64, + cancelled: &impl Fn() -> bool, +) -> Result { + let mut hash = Sha256::new(); + let mut done = 0; + while done < length { + if cancelled() { + return Err(Error::Cancelled); + } + let size = (length - done).min(super::super::DEFAULT_PAGE as u64) as usize; + hash.update(base.read(at + done, size)?); + done += size as u64; + } + Ok(format!("{:x}", hash.finalize())) +} +impl crate::binary::storage::StorageRoot { + /// Enumerate acknowledged versions for one authorized source and document. + /// This reads immutable evidence only, never the private command journal. + pub fn versions( + &self, + source: &Document, + document: &str, + after: Option<&str>, + limit: usize, + cancelled: impl Fn() -> bool, + ) -> Result { + use cap_fs_ext::DirExt; + valid_document(document)?; + if limit == 0 || limit > 64 || after.is_some_and(|id| !valid_version(id)) { + return Err(Error::Limit); + } + source.validate()?; + let root = self.directory()?; + let dir = root.open_dir_nofollow(document).map_err(|_| Error::File)?; + crate::binary::storage::private( + root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + let mut found = vec![]; + for (count, entry) in dir.entries().map_err(|_| Error::Io)?.enumerate() { + if cancelled() { + return Err(Error::Cancelled); + } + if count >= 4096 { + return Err(Error::Limit); + } + let name = entry.map_err(|_| Error::Io)?.file_name(); + let name = name.to_str().ok_or(Error::File)?; + let Some(version) = name.strip_suffix(".json").filter(|id| valid_version(id)) else { + continue; + }; + if after.is_some_and(|after| version <= after) { + continue; + } + let evidence = read_evidence(&dir, document, version)?; + bind_source(&evidence, source)?; + found.push(evidence.info); + found.sort_by(|a, b| a.version_id.cmp(&b.version_id)); + found.truncate(limit + 1); + } + source.validate()?; + self.directory()?; + let next_cursor = (found.len() > limit).then(|| found[limit - 1].version_id.clone()); + found.truncate(limit); + Ok(crate::binary::receipts::Versions { + versions: found, + next_cursor, + }) + } + /// Caller establishes fresh read authority for the live source resource first. + /// Saved versions are shared source history; unsaved owner/model drafts are not. + pub fn open_version( + &self, + source: &Document, + document: &str, + version: &str, + cancelled: impl Fn() -> bool, + ) -> Result { + use cap_fs_ext::DirExt; + valid_document(document)?; + if !valid_version(version) { + return Err(Error::Invalid); + } + source.validate()?; + let root = self.directory()?; + let dir = root.open_dir_nofollow(document).map_err(|_| Error::File)?; + crate::binary::storage::private( + root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + let evidence = read_evidence(&dir, document, version)?; + bind_source(&evidence, source)?; + let before = self.saved_base( + document, + &evidence.before.name, + evidence.before.length.0, + &evidence.before.sha256, + &cancelled, + )?; + let after = self.saved_base( + document, + &evidence.after.name, + evidence.after.length.0, + &evidence.after.sha256, + &cancelled, + )?; + source.validate()?; + Ok(VersionView { + evidence, + before, + after, + }) + } +} + +fn valid_document(document: &str) -> Result<(), Error> { + if !document.starts_with("bin_") + || document.len() > 100 + || !document + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(Error::Invalid); + } + Ok(()) +} +fn valid_version(version: &str) -> bool { + version.len() == 68 + && version.starts_with("ver_") + && version[4..].bytes().all(|b| b.is_ascii_hexdigit()) +} +fn read_evidence(dir: &cap_std::fs::Dir, document: &str, version: &str) -> Result { + let bytes = + crate::binary::storage::small_record(dir, &format!("{version}.json"), 16 * 1024 * 1024)?; + let evidence: Evidence = serde_json::from_slice(&bytes).map_err(|_| Error::Invalid)?; + evidence.validate_common(document)?; + if evidence.info.version_id != version { + return Err(Error::Changed); + } + Ok(evidence) +} +fn bind_source(evidence: &Evidence, source: &Document) -> Result<(), Error> { + if evidence.target.root != source.root() + || evidence.target.root.join(&evidence.target.relative) != source.resource().path() + || evidence.target.parents.first() != Some(&source.project_identity()?) + { + return Err(Error::File); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::binary::draft::{PatchBatch, Splice, encode_hex}; + fn fixture() -> (tempfile::TempDir, Editor, Owner) { + fixture_with(&[0, 255, 128, 27]) + } + fn fixture_with(content: &[u8]) -> (tempfile::TempDir, Editor, Owner) { + let temp = tempfile::tempdir().unwrap(); + std::fs::create_dir(temp.path().join("data")).unwrap(); + std::fs::create_dir(temp.path().join("project")).unwrap(); + let root = temp.path().join("project").canonicalize().unwrap(); + std::fs::write(root.join("bytes"), content).unwrap(); + let owner = Owner { + session_id: "ses_versions".into(), + directory: root.clone(), + root, + scope: DraftScope::Manual, + }; + let mut source = Document::open(&owner.root, "bytes").unwrap(); + let editor = Editor::create( + Store::open(&temp.path().join("data"), "bin_versions").unwrap(), + owner.clone(), + &mut source, + || false, + ) + .unwrap(); + (temp, editor, owner) + } + fn patch(editor: &mut Editor, id: &str, expected: &[u8], inserted: &[u8]) { + editor + .patch( + id, + &PatchBatch { + revision: editor.draft().revision(), + splices: vec![Splice { + offset: Offset(0), + delete_length: Offset(expected.len() as u64), + expected_hex: encode_hex(expected), + insert_hex: encode_hex(inserted), + }], + }, + ) + .unwrap(); + } + fn save(editor: &mut Editor, id: &str) { + let review = editor.review(0, 128, || false).unwrap(); + let SavePreparation::Ready(prepared) = editor + .prepare_save(id, &review.fingerprint, || false) + .unwrap() + else { + panic!("native save"); + }; + assert_eq!( + editor.commit_save(*prepared).unwrap().outcome, + Outcome::Acknowledged + ); + } + #[test] + fn saved_version_listing_is_bounded_and_excludes_unsaved_history() { + let (temp, mut editor, owner) = fixture(); + let storage = + crate::binary::storage::StorageRoot::prepare(&temp.path().join("data")).unwrap(); + patch(&mut editor, "patch_one", &[0], &[1]); + save(&mut editor, "saved_one"); + let first = editor.publish_last_version().unwrap().unwrap(); + patch(&mut editor, "patch_two", &[1], &[2]); + save(&mut editor, "saved_two"); + let second = editor.publish_last_version().unwrap().unwrap(); + patch(&mut editor, "private_patch", &[2], b"private unsaved bytes"); + let source = Document::open(&owner.root, "bytes").unwrap(); + let page = storage + .versions(&source, "bin_versions", None, 1, || false) + .unwrap(); + assert_eq!(page.versions.len(), 1); + let cursor = page.next_cursor.as_deref().unwrap(); + assert_eq!(cursor, page.versions[0].version_id); + let next = storage + .versions(&source, "bin_versions", Some(cursor), 1, || false) + .unwrap(); + assert_eq!(next.versions.len(), 1); + assert!(next.next_cursor.is_none()); + let mut actual = vec![ + page.versions[0].version_id.clone(), + next.versions[0].version_id.clone(), + ]; + let mut expected = vec![first.version_id, second.version_id]; + actual.sort(); + expected.sort(); + assert_eq!(actual, expected); + assert!( + storage + .versions(&source, "bin_versions", None, 65, || false) + .is_err() + ); + assert!(matches!( + storage.versions(&source, "bin_versions", None, 1, || true), + Err(Error::Cancelled) + )); + assert!( + storage + .versions(&source, "../bin_versions", None, 1, || false) + .is_err() + ); + std::fs::write(owner.root.join("other"), [0, 255]).unwrap(); + let other = Document::open(&owner.root, "other").unwrap(); + assert!( + storage + .versions(&other, "bin_versions", None, 1, || false) + .is_err() + ); + } + #[test] + fn saved_versions_retain_both_byte_images_without_transient_draft_commands() { + let (temp, mut editor, owner) = fixture(); + let secret = b"NEVER_SHARE_TRANSIENT_DRAFT_COMMAND"; + patch(&mut editor, "temporary", &[0], secret); + patch(&mut editor, "final", secret, &[1]); + assert!(editor.publish_last_version().unwrap().is_none()); + save(&mut editor, "saved_one"); + let first = editor.publish_last_version().unwrap().unwrap(); + assert_eq!(first.original_bytes, Offset(1)); + assert_eq!(first.proposed_bytes, Offset(1)); + assert_eq!(first.range_count, 1); + let first_file = format!("{}.json", first.version_id); + let bytes = editor.store.read_record(&first_file).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains(&encode_hex(secret))); + let evidence: Evidence = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + evidence + .before + .open(&editor.store) + .unwrap() + .read(0, 4) + .unwrap(), + [0, 255, 128, 27] + ); + assert_eq!( + evidence + .after + .open(&editor.store) + .unwrap() + .read(0, 4) + .unwrap(), + [1, 255, 128, 27] + ); + assert_eq!( + editor.publish_last_version().unwrap().unwrap().version_id, + first.version_id + ); + patch(&mut editor, "next_patch", &[1], &[2]); + save(&mut editor, "saved_two"); + let second = editor.publish_last_version().unwrap().unwrap(); + assert_ne!(first.version_id, second.version_id); + assert_eq!(second.before_sha256, first.after_sha256); + drop(editor); + let mut resumed = Editor::resume( + Store::open(&temp.path().join("data"), "bin_versions").unwrap(), + &owner, + ) + .unwrap(); + assert_eq!(resumed.store.read_record(&first_file).unwrap(), bytes); + assert_eq!( + resumed.publish_last_version().unwrap().unwrap().version_id, + second.version_id + ); + assert_eq!( + std::fs::read(owner.root.join("bytes")).unwrap(), + [2, 255, 128, 27] + ); + } + #[test] + fn shared_saved_views_exclude_current_private_drafts_and_bind_every_byte_to_its_source() { + let (temp, mut editor, owner) = fixture(); + patch(&mut editor, "patch_one", &[0], &[1]); + save(&mut editor, "saved"); + let info = editor.publish_last_version().unwrap().unwrap(); + patch( + &mut editor, + "unsaved_private", + &[1], + b"CURRENT_PRIVATE_DRAFT", + ); + let storage = + crate::binary::storage::StorageRoot::prepare(&temp.path().join("data")).unwrap(); + let source = Document::open(&owner.root, "bytes").unwrap(); + let view = storage + .open_version(&source, "bin_versions", &info.version_id, || false) + .unwrap(); + assert_eq!( + view.page(true, Offset(0), 4).unwrap().bytes, + [0, 255, 128, 27] + ); + assert_eq!( + view.page(false, Offset(0), 4).unwrap().bytes, + [1, 255, 128, 27] + ); + let review = view.review(0, 1, || false).unwrap(); + assert_eq!(review.ranges[0].before_hex, "00"); + assert_eq!(review.ranges[0].after_hex, "01"); + assert_eq!(review.range_count, 1); + assert!(matches!(view.review(0, 1, || true), Err(Error::Cancelled))); + assert!( + view.page(false, Offset(0), crate::binary::MAX_PAGE + 1) + .is_err() + ); + assert!(matches!( + storage.open_version(&source, "bin_versions", &info.version_id, || true), + Err(Error::Cancelled) + )); + std::fs::write(owner.root.join("other"), [0, 255, 128, 27]).unwrap(); + let other = Document::open(&owner.root, "other").unwrap(); + assert!( + storage + .open_version(&other, "bin_versions", &info.version_id, || false) + .is_err() + ); + assert!( + storage + .open_version(&source, "../bin_versions", &info.version_id, || false) + .is_err() + ); + assert_eq!( + std::fs::read(owner.root.join("bytes")).unwrap(), + [1, 255, 128, 27] + ); + assert!(editor.draft().can_undo()); + } + #[test] + fn semantically_identical_byte_layouts_checkpoint_without_replacement_or_new_versions() { + let (_temp, mut editor, owner) = fixture_with(b"aaaa"); + let original_id = editor.target().file.clone(); + patch(&mut editor, "remove_first", b"a", b""); + editor + .patch( + "insert_last", + &PatchBatch { + revision: editor.draft().revision(), + splices: vec![Splice { + offset: Offset(3), + delete_length: Offset(0), + expected_hex: String::new(), + insert_hex: "61".into(), + }], + }, + ) + .unwrap(); + let review = editor.review(0, 128, || false).unwrap(); + assert_eq!(review.draft.range_count, 0); + let SavePreparation::Existing(operation) = editor + .prepare_save("checkpoint", &review.fingerprint, || false) + .unwrap() + else { + panic!("identical bytes must not exchange"); + }; + assert_eq!(operation.outcome, Outcome::Acknowledged); + assert_eq!(operation.file_effect, FileEffect::None); + assert_eq!(editor.target().file, original_id); + assert!(!editor.draft().can_undo()); + assert!(editor.publish_last_version().unwrap().is_none()); + assert_eq!(std::fs::read(owner.root.join("bytes")).unwrap(), b"aaaa"); + } + + #[test] + fn unacknowledged_intents_and_corrupt_existing_versions_are_not_published_or_repaired() { + let (_temp, mut editor, owner) = fixture(); + patch(&mut editor, "patch_one", &[0], &[1]); + let review = editor.review(0, 128, || false).unwrap(); + let SavePreparation::Ready(prepared) = editor + .prepare_save("pending", &review.fingerprint, || false) + .unwrap() + else { + panic!("prepare"); + }; + editor.record.pending = Some(prepared.intent); + persist(&mut editor.store, &editor.record).unwrap(); + assert!(editor.publish_last_version().unwrap().is_none()); + assert_eq!( + std::fs::read(owner.root.join("bytes")).unwrap(), + [0, 255, 128, 27] + ); + // Separate fixture: corrupt metadata must not be silently replaced. + let (_temp, mut editor, owner) = fixture(); + patch(&mut editor, "patch_one", &[0], &[1]); + save(&mut editor, "saved"); + let version = editor.publish_last_version().unwrap().unwrap(); + let name = format!("{}.json", version.version_id); + editor + .store + .record(&name, 2, |writer| { + writer.write_all(b"{}").map_err(|_| Error::Io) + }) + .unwrap(); + assert!(editor.publish_last_version().is_err()); + assert_eq!(editor.store.read_record(&name).unwrap(), b"{}"); + assert_eq!( + std::fs::read(owner.root.join("bytes")).unwrap(), + [1, 255, 128, 27] + ); + } +} diff --git a/crates/cyber-core/src/binary/receipts.rs b/crates/cyber-core/src/binary/receipts.rs index e134743a..5aca4efd 100644 --- a/crates/cyber-core/src/binary/receipts.rs +++ b/crates/cyber-core/src/binary/receipts.rs @@ -43,3 +43,31 @@ pub struct SaveReview { pub effects: String, pub fingerprint: String, } + +/// Shared saved-source metadata. It never contains unsaved command buffers. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryVersion")] +pub struct Version { + pub version_id: String, + pub document_id: String, + pub operation_id: String, + pub kind: String, + pub path: String, + pub before_sha256: String, + pub after_sha256: String, + pub before_length: super::Offset, + pub after_length: super::Offset, + pub original_bytes: super::Offset, + pub proposed_bytes: super::Offset, + pub range_count: usize, + pub created_ms: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(rename = "BinaryVersions")] +pub struct Versions { + pub versions: Vec, + pub next_cursor: Option, +} diff --git a/crates/cyber-core/src/binary/source.rs b/crates/cyber-core/src/binary/source.rs index e24c1619..41ea0be5 100644 --- a/crates/cyber-core/src/binary/source.rs +++ b/crates/cyber-core/src/binary/source.rs @@ -101,6 +101,11 @@ impl Document { Ok(doc) } + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(super) fn project_identity(&self) -> Result { + super::native::id(self.directories.first().ok_or(Error::File)?.as_file()) + } + /// Preparation requires the caller's fresh read+edit admission. It refuses /// mutation aliases and creates an immutable private base, never changing the target. #[cfg(any(target_os = "macos", target_os = "linux"))] diff --git a/crates/cyber-core/src/binary/storage.rs b/crates/cyber-core/src/binary/storage.rs index 82b02465..561fd725 100644 --- a/crates/cyber-core/src/binary/storage.rs +++ b/crates/cyber-core/src/binary/storage.rs @@ -135,6 +135,36 @@ impl StorageRoot { let data = open_data_directory(&self.data)?; Store::locked(self.data.clone(), data, root, dir, id) } + /// Read acknowledged immutable evidence without acquiring or manufacturing a + /// mutation lock. Only the saved-version service can select these object names. + #[cfg(any(target_os = "macos", target_os = "linux"))] + pub(super) fn saved_base( + &self, + document: &str, + name: &str, + length: u64, + digest: &str, + cancelled: impl Fn() -> bool, + ) -> Result { + component(document)?; + let root = self.directory()?; + let dir = root.open_dir_nofollow(document).map_err(|_| Error::File)?; + private( + root, + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + true, + )?; + let dir = Arc::new(dir); + let base = immutable_base(dir.clone(), name, length, digest, cancelled)?; + let current = root + .open_dir_nofollow(document) + .map_err(|_| Error::Changed)?; + if identity(¤t)? != identity(&dir)? { + return Err(Error::Changed); + } + self.directory()?; + Ok(base) + } /// A writable project containing private storage could rename or inspect the /// staging directory under its filesystem grant. Such editing is unsupported. pub fn admit_project(&self, project: &Path) -> Result<(), Error> { @@ -707,58 +737,69 @@ impl Store { digest: &str, cancelled: impl Fn() -> bool, ) -> Result { + self.validate()?; + let base = immutable_base(self.dir.clone(), name, length, digest, cancelled)?; + self.validate()?; + Ok(base) + } +} +fn immutable_base( + dir: Arc, + name: &str, + length: u64, + digest: &str, + cancelled: impl Fn() -> bool, +) -> Result { + if cancelled() { + return Err(Error::Cancelled); + } + if length > MAX_EDIT_SIZE { + return Err(Error::Limit); + } + let file = read_file(&dir, name)?; + if file.metadata().map_err(|_| Error::Io)?.len() != length { + return Err(Error::Changed); + } + #[cfg(windows)] + let file = { + let expected = crate::memory::windows::identity(&file).map_err(|_| Error::File)?; + drop(file); + crate::memory::windows::freeze_private_file( + &dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), + name, + expected, + ) + .map_err(|_| Error::File)? + }; + let before = generation(&file)?; + let mut observed = Sha256::new(); + let mut at = 0; + while at < length { if cancelled() { return Err(Error::Cancelled); } - self.validate()?; - if length > MAX_EDIT_SIZE { - return Err(Error::Limit); - } - let file = read_file(&self.dir, name)?; - if file.metadata().map_err(|_| Error::Io)?.len() != length { - return Err(Error::Changed); - } - #[cfg(windows)] - let file = { - let expected = crate::memory::windows::identity(&file).map_err(|_| Error::File)?; - drop(file); - crate::memory::windows::freeze_private_file( - &self.dir.try_clone().map_err(|_| Error::Io)?.into_std_file(), - name, - expected, - ) - .map_err(|_| Error::File)? - }; - let before = generation(&file)?; - let mut observed = Sha256::new(); - let mut at = 0; - while at < length { - if cancelled() { - return Err(Error::Cancelled); - } - let take = (length - at).min(DEFAULT_PAGE as u64) as usize; - let bytes = positioned(&file, at, take)?; - observed.update(&bytes); - at += take as u64; - } - if format!("{:x}", observed.finalize()) != digest { - return Err(Error::Changed); - } - let observed = generation(&file)?; - if before != observed { - return Err(Error::Changed); - } - Ok(Base { - inner: Arc::new(BaseInner { - file, - _dir: self.dir.clone(), - name: name.into(), - length, - digest: digest.into(), - generation: observed, - }), - }) + let take = (length - at).min(DEFAULT_PAGE as u64) as usize; + let bytes = positioned(&file, at, take)?; + observed.update(&bytes); + at += take as u64; + } + if format!("{:x}", observed.finalize()) != digest { + return Err(Error::Changed); + } + let observed = generation(&file)?; + if before != observed { + return Err(Error::Changed); } + Ok(Base { + inner: Arc::new(BaseInner { + file, + _dir: dir.clone(), + name: name.into(), + length, + digest: digest.into(), + generation: observed, + }), + }) } fn positioned(file: &File, offset: u64, length: usize) -> Result, Error> { let mut bytes = vec![0; length]; diff --git a/crates/cyber-server/src/http/binary_files/editing.rs b/crates/cyber-server/src/http/binary_files/editing.rs index 9cc95044..b933d246 100644 --- a/crates/cyber-server/src/http/binary_files/editing.rs +++ b/crates/cyber-server/src/http/binary_files/editing.rs @@ -225,6 +225,7 @@ pub(super) async fn prepare( resource, (authority.grant, grant), move |cancel| { + cancel.verify()?; let mut content = entry.document.lock().map_err(|_| Error::Io)?; if content.metadata().revision != input.revision { return Err(Error::Changed); @@ -338,6 +339,7 @@ pub(super) async fn patch( let Content::Draft(editor) = &mut *content else { return Err(Error::Invalid); }; + cancel.verify()?; let operation = editor.patch( &input.operation_id, &PatchBatch { @@ -399,6 +401,7 @@ pub(super) async fn history( let Content::Draft(editor) = &mut *content else { return Err(Error::Invalid); }; + cancel.verify()?; let operation = match input.action { HistoryAction::Undo => { editor.history(&input.operation_id, &input.revision, false)? diff --git a/crates/cyber-server/src/runtime/binary_documents.rs b/crates/cyber-server/src/runtime/binary_documents.rs index 1c0ea20c..449942d7 100644 --- a/crates/cyber-server/src/runtime/binary_documents.rs +++ b/crates/cyber-server/src/runtime/binary_documents.rs @@ -64,6 +64,58 @@ pub(crate) struct EditAdmission { pub authority: super::AdmissionAuthority, } +/// Retained host authority for the actual filesystem worker. Verification only +/// refreshes trusted policy; prompts and hook/model execution belong before it. +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(crate) struct BinaryWork { + runtime: super::Runtime, + executor: tokio::runtime::Handle, + turn: super::TurnContext, + resource: cyber_core::binary::Resource, + grants: (super::FileDecision, super::FileDecision), + authority: super::AdmissionAuthority, + cancel: tokio_util::sync::CancellationToken, +} +#[cfg(any(target_os = "macos", target_os = "linux"))] +impl BinaryWork { + pub fn is_cancelled(&self) -> bool { + self.cancel.is_cancelled() + } + /// Invoke from the blocking filesystem worker, immediately before its effect. + pub fn verify(&self) -> Result<(), Error> { + if self.is_cancelled() { + return Err(Error::Cancelled); + } + self.authority + .verify(&self.runtime, &self.turn.session_id) + .map_err(|_| Error::Changed)?; + self.executor.block_on(async { + let current = self + .runtime + .file_turn( + Some(&self.turn.session_id), + std::path::Path::new(&self.turn.directory), + ) + .await + .map_err(|_| Error::Changed)?; + if current != self.turn { + return Err(Error::Changed); + } + self.runtime + .verify_binary_grants(&self.turn, &self.resource, self.grants) + .await + .map_err(|_| Error::Changed) + })?; + self.authority + .verify(&self.runtime, &self.turn.session_id) + .map_err(|_| Error::Changed)?; + if self.is_cancelled() { + return Err(Error::Cancelled); + } + Ok(()) + } +} + pub(crate) struct Entry { pub session_id: String, pub directory: String, @@ -262,7 +314,7 @@ impl super::Runtime { document: String, resource: cyber_core::binary::Resource, grants: (super::FileDecision, super::FileDecision), - work: impl FnOnce(tokio_util::sync::CancellationToken) -> T + Send + 'static, + work: impl FnOnce(BinaryWork) -> T + Send + 'static, ) -> Result { let edit_permit = self .binary_documents() @@ -284,6 +336,7 @@ impl super::Runtime { "Binary operation scope changed".into(), )); } + let authority = self.capture_child_admission(&turn.session_id)?; let mut activity = super::activity::Scope::reserve_binary(&self.inner, &handle, document).await?; let cancel = activity.control.stop.clone(); @@ -323,11 +376,20 @@ impl super::Runtime { .binary_documents() .worker() .map_err(|error| super::RuntimeError::Invalid(error.to_string()))?; + let context = BinaryWork { + runtime: self.clone(), + executor: tokio::runtime::Handle::current(), + turn, + resource, + grants, + authority, + cancel, + }; tokio::task::spawn_blocking(move || { let (_lifecycle, _location, _paths, _permit, _edit_permit) = (lifecycle, location, paths, permit, edit_permit); activity.launch()?; - let result = work(cancel); + let result = work(context); // A returned unknown file outcome is not a live/unowned native process. // Its durable intent remains unresolved in the document journal. activity.settle()?; diff --git a/crates/cyber-server/src/runtime/binary_documents_tests.rs b/crates/cyber-server/src/runtime/binary_documents_tests.rs index 565dd3ba..c8d9a111 100644 --- a/crates/cyber-server/src/runtime/binary_documents_tests.rs +++ b/crates/cyber-server/src/runtime/binary_documents_tests.rs @@ -13,7 +13,10 @@ impl ModelResolver for Models { Err("inference is forbidden in this test".into()) } } -struct Host(Arc); +struct Host( + Arc, + Arc, +); impl ToolHost for Host { fn definitions(&self, _: &TurnContext) -> Vec { vec![] @@ -27,7 +30,13 @@ impl ToolHost for Host { _: &'a str, _: &'a cyber_core::binary::Resource, ) -> BoxFuture<'a, FileDecision> { - Box::pin(async { FileDecision::Allow }) + Box::pin(async { + if self.1.load(std::sync::atomic::Ordering::SeqCst) { + FileDecision::Deny + } else { + FileDecision::Allow + } + }) } fn file_mutation_guard<'a>( &'a self, @@ -48,6 +57,17 @@ async fn fixture() -> ( String, cyber_core::binary::Resource, Arc, +) { + fixture_with_policy(Arc::new(std::sync::atomic::AtomicBool::new(false))).await +} +async fn fixture_with_policy( + denied: Arc, +) -> ( + tempfile::TempDir, + Runtime, + String, + cyber_core::binary::Resource, + Arc, ) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().canonicalize().unwrap(); @@ -65,7 +85,7 @@ async fn fixture() -> ( .unwrap(), ), resolver: Arc::new(Models), - tools: Arc::new(Host(paths.clone())), + tools: Arc::new(Host(paths.clone(), denied)), global_config_dir: root.join("config"), shell: "test".into(), claude_compat: false, @@ -88,6 +108,50 @@ async fn fixture() -> ( (temp, runtime, session, resource, paths) } +#[tokio::test] +async fn binary_worker_refreshes_policy_after_preparation_without_releasing_owners() { + let denied = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (temp, runtime, session, resource, paths) = fixture_with_policy(denied.clone()).await; + let turn = runtime + .file_turn(Some(&session), temp.path()) + .await + .unwrap(); + let (started, running) = tokio::sync::oneshot::channel(); + let (finish, wait) = std::sync::mpsc::channel(); + let worker_paths = paths.clone(); + let task = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .binary_file_work( + turn, + "bin_final_policy".into(), + resource, + (FileDecision::Allow, FileDecision::Allow), + move |work| { + work.verify().unwrap(); + started.send(()).unwrap(); + wait.recv().unwrap(); + assert_eq!(worker_paths.available_permits(), 0); + work.verify() + }, + ) + .await + }) + }; + running.await.unwrap(); + denied.store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!(paths.available_permits(), 0); + finish.send(()).unwrap(); + assert!(matches!( + task.await.unwrap().unwrap(), + Err(cyber_core::binary::Error::Changed) + )); + assert_eq!(paths.available_permits(), 1); + assert_eq!(std::fs::read(temp.path().join("bytes")).unwrap(), [0, 255]); + runtime.shutdown().await; +} + #[tokio::test] async fn disposed_binary_request_retains_worker_ownership_until_actual_exit() { let (temp, runtime, session, resource, paths) = fixture().await; diff --git a/crates/cyber-server/src/runtime/host.rs b/crates/cyber-server/src/runtime/host.rs index b8140b77..3f21a477 100644 --- a/crates/cyber-server/src/runtime/host.rs +++ b/crates/cyber-server/src/runtime/host.rs @@ -1210,6 +1210,8 @@ pub trait ToolHost: Send + Sync { /// Decisions use the document's recorded canonical resource. Resolving this /// name through a later symlink would check permission for the wrong bytes. + /// This is a trusted policy-only query: no prompts, hooks, model calls or + /// subprocesses. It may run while a filesystem worker retains mutation owners. fn binary_file_decision<'a>( &'a self, _turn: &'a TurnContext, diff --git a/openspec/changes/add-binary-hex-editor/implementation.md b/openspec/changes/add-binary-hex-editor/implementation.md index 1163b1b2..5e25f6be 100644 --- a/openspec/changes/add-binary-hex-editor/implementation.md +++ b/openspec/changes/add-binary-hex-editor/implementation.md @@ -402,3 +402,49 @@ the actual acknowledgement record shape in the capacity forecast, lock-file bind and refusal to recreate missing recovery locks. Linux/macOS/Windows CI must still validate this follow-up. PR #97 merged at `d6a9b1ae` after all 24 current-head checks passed, including its Windows-specific live-view/unsupported-edit regression. + +## Acknowledged saved-version evidence + +Native save/restore intents now retain the proposed immutable before/after base +references and original-offset range mapping. Only a durably acknowledged changed +operation can publish an immutable saved-version record. Publication is idempotent; +an existing different/corrupt record refuses replacement. The next native operation +must preserve the preceding acknowledged evidence before replacing Last Save. +Interrupted/unknown intents cannot publish successful history. Evidence contains +offsets, hashes and source-version metadata, never transient draft command buffers. + +The core exposes bounded, cancellable saved-version discovery, byte pages and +original-coordinate review under a currently authorized source's pinned root/path. +These views do not acquire another owner's draft lock or read their journal. Both +immutable images are verified on open, including for non-git binary sources. +Private namespace traversal and mismatched source paths refuse access. Identical +full byte content checkpoints without native replacement or a saved-change record, +even when piece-table edits changed the internal layout. + +The local binary suite passes 65 tests (one child helper invoked by parent tests +is ignored by the ordinary selector), and core library/test Clippy passes. New +regressions cover repeated saves/restart, excluded transient bytes, private dirty +draft isolation, source binding, pagination/cancellation, no-op saves and refusal +to publish unknown/corrupt evidence. This is core history infrastructure: public +save/restore/history integration, retention, durable FileChanged dispatch and the +remaining terminal/model/acceptance work are still outstanding. Platform CI remains +required for this increment. + +## Final worker authority checkpoint + +The retained filesystem worker now carries its captured ancestor authority, +original Turn/Location, frozen resource, granted read/edit decisions and Stop token. +Its trusted policy-only checkpoint refreshes current Turn and both decisions on +the actual blocking worker while keeping native Location, workspace/path and +lifecycle ownership. This query cannot prompt or run hooks/models/subprocesses. +Patch/history invoke it before their private journal effects. Core native commit +accepts a final checkpoint after full source/stage verification and capacity +reservation, immediately before intent publication, and rechecks cancellation. +Production save routes must supply the retained worker checkpoint there. + +Regressions verify policy revocation after preparation while path ownership stays +held, refusal without source changes/pending intent/fabricated receipts, Stop at +the final check and an explicitly authorized successful follow-up. Local results: +66 binary core tests, five runtime worker/proof tests and all 23 production file +route tests passed; affected core/server library/test Clippy passed. This does not +activate public save or complete the outstanding history/notification integrations.