diff --git a/Cargo.toml b/Cargo.toml index 8ec3937c4..d27fbe740 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,9 @@ members = [ # in members nor exclude, so `cargo test -p ruvector-agent-memory` # could not run at all. "crates/ruvector-agent-memory", + # StagedWorkspace (arXiv:2608.18050) content-hash state binding + # (ADR-318, PIR WP16 ruvnet/RuVector#863). + "crates/ruvector-staged-workspace", "crates/ruvector-retrieval-receipt", "crates/ruvector-gnn-rerank", "crates/ruvector-gnn-node", diff --git a/crates/ruvector-staged-workspace/Cargo.toml b/crates/ruvector-staged-workspace/Cargo.toml new file mode 100644 index 000000000..2b36e9cd0 --- /dev/null +++ b/crates/ruvector-staged-workspace/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "ruvector-staged-workspace" +version = "0.1.0" +edition = "2021" +description = "StagedWorkspace (arXiv:2608.18050) content-hash + revision-id state binding for RuV artifacts: stale views fail closed (ADR-318, PIR WP16)" +authors = ["ruvnet", "claude-flow"] +license = "MIT OR Apache-2.0" +repository = "https://github.com/ruvnet/ruvector" +keywords = ["content-hash", "provenance", "workspace", "agent", "ruvector"] +categories = ["data-structures"] + +[dependencies] +serde = { workspace = true } +# The repo's existing RVF-layer SHA-256 (FIPS 180-4, NIST-vector verified, +# no_std, zero deps). ADR-318 §Decision 1 reuses RVF's canonical-format +# discipline; the program's canonical receipt contract (ruflo ADR-322C, +# adopted via ADR-312) specifies SHA-256 — no new hash is introduced. +rvf-types = { version = "0.2", path = "../rvf/rvf-types", default-features = false } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/crates/ruvector-staged-workspace/src/anchor.rs b/crates/ruvector-staged-workspace/src/anchor.rs new file mode 100644 index 000000000..f224b328b --- /dev/null +++ b/crates/ruvector-staged-workspace/src/anchor.rs @@ -0,0 +1,206 @@ +//! ADR-312 anchoring seam: workspace-state transitions as witness/receipt +//! records (ADR-318 §Decision 4). +//! +//! ADR-312 resolves the program's witness layer as a *shared record schema +//! plus a cross-layer anchoring contract*: records that must verify across +//! layers use ruflo ADR-322C's canonical encoding, SHA-256 identity +//! derivation, and domain-separated Ed25519 signatures +//! (`Ed25519(domainPrefix || 0x00 || canonicalBytes)`). This module defines +//! that boundary for workspace-state transitions: +//! +//! - [`WorkspaceTransitionRecord`] — the record for one artifact-lineage +//! transition, with [`canonical_bytes`](WorkspaceTransitionRecord::canonical_bytes) +//! (fixed field order, length-prefixed strings, little-endian integers), +//! a SHA-256 [`record_id`](WorkspaceTransitionRecord::record_id) mirroring +//! 322C's `receiptId = SHA-256(canonical unsigned payload)`, and a +//! domain-separated [`signing_preimage`](WorkspaceTransitionRecord::signing_preimage). +//! - [`TransitionAnchor`] — the trait the workspace emits records through. +//! Wiring the full ruflo ADR-322C receipt emission (RFC 8785 JCS + +//! Ed25519, ruflo#3066 formal spec) is deliberately out of this slice; a +//! real implementation lives behind this trait, the same way +//! `ruvector-agent-memory`'s `WitnessSink` stubs its WP8 RVM anchoring. +//! +//! **Fail-closed contract**: if `anchor` returns `Err`, the workspace +//! transition is aborted — "no anchor, no transition", matching ADR-134's +//! "no witness, no mutation" invariant already enforced by the WP4 ledger. + +use serde::{Deserialize, Serialize}; + +use crate::artifact::ContentHash; + +/// Domain-separation prefix for workspace-transition signing preimages, +/// following ADR-322C's `domainPrefix || 0x00 || canonicalBytes` scheme +/// (distinct from ruflo's `ruflo/flywheel-receipt/v1` and +/// `ruflo/flywheel-ledger-head/v1` domains). +pub const TRANSITION_DOMAIN: &str = "ruv/staged-workspace-transition/v1"; + +/// Evidence grade, using the three-value vocabulary of the program's +/// canonical witness/receipt contract (ruflo ADR-322C, adopted via +/// ADR-312) — the same vocabulary `ruvector-agent-memory::EvidenceGrade` +/// uses for TARL ledger transitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EvidenceGrade { + /// Re-derived from primary data (e.g. the workspace recomputed the + /// content hash from the artifact's actual bytes at commit time). + Recomputed, + /// Backed by a verified cryptographic signature (produced only by a + /// real ADR-322C anchor implementation; never by [`NoopAnchor`]). + SignatureVerified, + /// Asserted without independent recomputation or signature. + TrustedAssertion, +} + +impl EvidenceGrade { + /// Compact code bound into the canonical encoding + /// (1 = recomputed, 2 = signature-verified, 3 = trusted-assertion). + pub fn code(self) -> u8 { + match self { + EvidenceGrade::Recomputed => 1, + EvidenceGrade::SignatureVerified => 2, + EvidenceGrade::TrustedAssertion => 3, + } + } +} + +/// One artifact-lineage transition (create or update), as anchored at the +/// ADR-312 boundary. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspaceTransitionRecord { + /// Workspace-scoped monotonic transition sequence number. + pub sequence: u64, + /// Wall-clock nanoseconds at commit time. + pub timestamp_ns: u64, + /// Lineage identity of the artifact that changed. + pub artifact_id: String, + /// Content hash of the version being superseded (`None` on create). + pub prior_hash: Option, + /// Revision id being superseded (`None` on create). + pub prior_revision: Option, + /// Content hash of the newly committed version. + pub new_hash: ContentHash, + /// Revision id of the newly committed version. + pub new_revision: u64, + /// Identity of the actor that committed the write. + pub actor_id: String, + /// Evidence grade for the `new_hash` claim. The workspace always sets + /// [`EvidenceGrade::Recomputed`]: it derived the hash from the + /// artifact's actual bytes, not from a caller's assertion. + pub evidence_grade: EvidenceGrade, +} + +impl WorkspaceTransitionRecord { + /// Canonical byte encoding: fixed field order, little-endian integers, + /// u64-length-prefixed strings, `0x00`/`0x01` option tags. This is the + /// crate-local stand-in for ADR-322C's RFC 8785 JCS canonical JSON — + /// full JCS shape conformance is the ruflo#3066 follow-up, behind + /// [`TransitionAnchor`]. + pub fn canonical_bytes(&self) -> Vec { + fn put_str(out: &mut Vec, s: &str) { + out.extend_from_slice(&(s.len() as u64).to_le_bytes()); + out.extend_from_slice(s.as_bytes()); + } + let mut out = Vec::with_capacity(160 + self.artifact_id.len() + self.actor_id.len()); + out.extend_from_slice(&self.sequence.to_le_bytes()); + out.extend_from_slice(&self.timestamp_ns.to_le_bytes()); + put_str(&mut out, &self.artifact_id); + match (&self.prior_hash, self.prior_revision) { + (Some(h), Some(r)) => { + out.push(0x01); + out.extend_from_slice(&h.0); + out.extend_from_slice(&r.to_le_bytes()); + } + _ => out.push(0x00), + } + out.extend_from_slice(&self.new_hash.0); + out.extend_from_slice(&self.new_revision.to_le_bytes()); + put_str(&mut out, &self.actor_id); + out.push(self.evidence_grade.code()); + out + } + + /// Record identity: `SHA-256(canonical_bytes)`, mirroring ADR-322C's + /// `receiptId = SHA-256(JCS(unsigned receipt payload))` derivation. + pub fn record_id(&self) -> ContentHash { + ContentHash::of(&self.canonical_bytes()) + } + + /// Signing preimage per ADR-322C's domain-separation scheme: + /// `TRANSITION_DOMAIN || 0x00 || canonical_bytes`. A real anchor signs + /// this with Ed25519; this slice defines the preimage but does not sign. + pub fn signing_preimage(&self) -> Vec { + let canonical = self.canonical_bytes(); + let mut out = Vec::with_capacity(TRANSITION_DOMAIN.len() + 1 + canonical.len()); + out.extend_from_slice(TRANSITION_DOMAIN.as_bytes()); + out.push(0x00); + out.extend_from_slice(&canonical); + out + } +} + +/// Receipt returned by an anchor for one accepted transition record. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AnchorReceipt { + /// The anchored record's identity (`SHA-256` of its canonical bytes). + pub record_id: ContentHash, + /// How strongly the anchoring itself is evidenced: + /// [`EvidenceGrade::TrustedAssertion`] for [`NoopAnchor`]; + /// [`EvidenceGrade::SignatureVerified`] for a real ADR-322C + /// Ed25519-signing anchor. + pub grade: EvidenceGrade, +} + +/// Error returned by an anchor that refuses a record. Per the fail-closed +/// contract, the workspace aborts the transition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AnchorError(pub String); + +impl core::fmt::Display for AnchorError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "anchor rejected transition record: {}", self.0) + } +} + +impl std::error::Error for AnchorError {} + +/// The ADR-312 anchoring seam. A workspace-state transition becomes durable +/// only if its record is accepted here; rejection aborts the transition +/// ("no anchor, no transition"). +pub trait TransitionAnchor { + /// Anchor one transition record, returning a receipt on acceptance. + fn anchor(&mut self, record: &WorkspaceTransitionRecord) -> Result; +} + +/// Accepts every record without signing — grades the anchoring +/// [`EvidenceGrade::TrustedAssertion`]. The in-process default, mirroring +/// `ruvector-agent-memory::NoopWitnessSink`; production use requires a real +/// ADR-322C anchor behind [`TransitionAnchor`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAnchor; + +impl TransitionAnchor for NoopAnchor { + fn anchor(&mut self, record: &WorkspaceTransitionRecord) -> Result { + Ok(AnchorReceipt { + record_id: record.record_id(), + grade: EvidenceGrade::TrustedAssertion, + }) + } +} + +/// Rejects every record — used to prove the fail-closed "no anchor, no +/// transition" property in tests. +#[derive(Debug, Default, Clone)] +pub struct RejectingAnchor { + /// Reason echoed in the [`AnchorError`]. + pub reason: String, +} + +impl TransitionAnchor for RejectingAnchor { + fn anchor(&mut self, _: &WorkspaceTransitionRecord) -> Result { + Err(AnchorError(if self.reason.is_empty() { + "rejecting anchor".to_string() + } else { + self.reason.clone() + })) + } +} diff --git a/crates/ruvector-staged-workspace/src/artifact.rs b/crates/ruvector-staged-workspace/src/artifact.rs new file mode 100644 index 000000000..a254d26e2 --- /dev/null +++ b/crates/ruvector-staged-workspace/src/artifact.rs @@ -0,0 +1,78 @@ +//! Artifact identity: content hash + revision id (ADR-318 §Decision 1). + +use core::fmt; +use serde::{Deserialize, Serialize}; + +/// SHA-256 content hash of an artifact's canonical byte representation. +/// +/// Computed with the repo's existing RVF-layer SHA-256 +/// ([`rvf_types::sha256`], FIPS 180-4, verified against NIST vectors) — the +/// same SHA-256 the program's canonical receipt/witness contract (ruflo +/// ADR-322C, adopted via ADR-312) specifies. ADR-318 explicitly reuses the +/// existing hashing discipline rather than introducing a new hash. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ContentHash(pub [u8; 32]); + +impl ContentHash { + /// Hash `content` (the artifact's canonical bytes). + pub fn of(content: &[u8]) -> Self { + Self(rvf_types::sha256::sha256(content)) + } + + /// Lowercase hex rendering (64 chars). + pub fn to_hex(&self) -> String { + let mut s = String::with_capacity(64); + for b in self.0 { + use core::fmt::Write; + let _ = write!(s, "{b:02x}"); + } + s + } +} + +impl fmt::Display for ContentHash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_hex()) + } +} + +/// A reference to one **exact version** of one artifact. +/// +/// This is the binding unit of ADR-318: every downstream reference (parser +/// result, tool call, diff, approval, output — see [`crate::ViewKind`]) +/// carries an `ArtifactRef`, i.e. the content hash *and* revision id of the +/// version it read, not merely the artifact's identity. Modeled on +/// StagedWorkspace's (arXiv:2608.18050) binding of parsed records and review +/// diffs to native-file content hashes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactRef { + /// Stable identity of the artifact's lineage (e.g. a workspace path or + /// RVF record id). Identity alone is NOT a version reference. + pub artifact_id: String, + /// SHA-256 of the exact version's canonical bytes. + pub content_hash: ContentHash, + /// Monotonic revision counter scoped to this artifact's lineage + /// (starts at 1 for the first committed version). + pub revision_id: u64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hash_matches_known_sha256_vector() { + // NIST vector: SHA-256("abc") + let h = ContentHash::of(b"abc"); + assert_eq!( + h.to_hex(), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn display_is_hex() { + let h = ContentHash::of(b""); + assert_eq!(format!("{h}").len(), 64); + } +} diff --git a/crates/ruvector-staged-workspace/src/lib.rs b/crates/ruvector-staged-workspace/src/lib.rs new file mode 100644 index 000000000..c37fbfe96 --- /dev/null +++ b/crates/ruvector-staged-workspace/src/lib.rs @@ -0,0 +1,58 @@ +//! # ruvector-staged-workspace +//! +//! Content-hash + revision-id state binding for RuV artifacts, implementing +//! the **StagedWorkspace (arXiv:2608.18050)** pattern as a RuV invariant +//! per **ADR-318** (`docs/adr/ADR-318-stagedworkspace-content-hash-state-binding.md`, +//! PIR WP16, ruvnet/RuVector#863). +//! +//! ## The invariant (ADR-318 §Decision) +//! +//! 1. **Every artifact gets a content hash and a revision id at write time.** +//! The hash is SHA-256 over the artifact's canonical byte representation, +//! computed with the repo's existing RVF-layer implementation +//! ([`rvf_types::sha256`]) — the same SHA-256 the program's canonical +//! receipt contract (ruflo ADR-322C, adopted via ADR-312) specifies. +//! 2. **Every downstream reference binds to that exact hash**, not just the +//! artifact's identity: a [`StagedView`] records the [`ArtifactRef`] +//! (hash + revision) of the exact version it was derived from, for all +//! five view kinds ADR-318 enumerates ([`ViewKind`]: parser result, tool +//! call, diff, approval, output). +//! 3. **Stale state is automatically invalid.** When the workspace's live +//! hash for an artifact no longer matches a view's recorded hash, +//! [`WorkspaceState::validate`] and [`WorkspaceState::execute`] reject +//! the view with a hard error — a structural, fail-closed rejection at +//! read time, never a soft warning. +//! 4. Workspace-state transitions are anchored through the **ADR-312 seam** +//! ([`TransitionAnchor`]): each commit produces a +//! [`WorkspaceTransitionRecord`] with a canonical byte encoding, a +//! SHA-256 record id (mirroring ADR-322C's `receiptId = SHA-256(canonical +//! payload)` identity derivation), and a domain-separated signing +//! preimage (`domain || 0x00 || canonicalBytes`, ADR-322C's scheme). +//! Per the "no anchor, no transition" discipline (the same fail-closed +//! posture as ADR-134's "no witness, no mutation" in +//! `ruvector-agent-memory`), an anchor rejection aborts the commit. +//! +//! ## Preprint-reproduction rule (read before citing numbers) +//! +//! StagedWorkspace (arXiv:2608.18050) reports +8.3–12.1pp OfficeQA Pass@1 +//! for dual parsed/native access on the paper's own models and reference +//! implementation, **which is not publicly released** ("Under Review", no +//! repo — verified in `06-wave2-evidence-review.md` §5). This crate is a +//! first-party implementation from the paper's description. **The paper's +//! figure is NOT claimed here**: per ADR-318 §Decision 5 and ADR-306, +//! promotion requires this program's own `research-gate`-recomputed delta on +//! an internal OfficeQA-equivalent task set — a future benchmark deliverable, +//! not part of this slice. + +pub mod anchor; +pub mod artifact; +pub mod view; +pub mod workspace; + +pub use anchor::{ + AnchorError, AnchorReceipt, EvidenceGrade, NoopAnchor, RejectingAnchor, TransitionAnchor, + WorkspaceTransitionRecord, TRANSITION_DOMAIN, +}; +pub use artifact::{ArtifactRef, ContentHash}; +pub use view::{StagedView, ViewKind}; +pub use workspace::{ViewConflict, WorkspaceError, WorkspaceState}; diff --git a/crates/ruvector-staged-workspace/src/view.rs b/crates/ruvector-staged-workspace/src/view.rs new file mode 100644 index 000000000..5a630052d --- /dev/null +++ b/crates/ruvector-staged-workspace/src/view.rs @@ -0,0 +1,60 @@ +//! Staged views: parsed/derived state bound to the exact artifact version it +//! came from (ADR-318 §Decision 2). + +use serde::{Deserialize, Serialize}; + +use crate::artifact::{ArtifactRef, ContentHash}; + +/// The five downstream-reference kinds ADR-318 enumerates. Each one is a +/// piece of derived state that reads or acts on an artifact and must record +/// the content hash (and revision id) of the exact version it used. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ViewKind { + /// A parser's structured output over the artifact's native bytes + /// (StagedWorkspace's "parsed records"). + ParserResult, + /// A tool call whose input read the artifact. + ToolCall, + /// A review diff computed against the artifact + /// (StagedWorkspace's "review diffs"). + Diff, + /// An approval decision made while looking at the artifact. + Approval, + /// A generated output derived from the artifact. + Output, +} + +impl ViewKind { + /// All five kinds, for exhaustive iteration in tests and audits. + pub const ALL: [ViewKind; 5] = [ + ViewKind::ParserResult, + ViewKind::ToolCall, + ViewKind::Diff, + ViewKind::Approval, + ViewKind::Output, + ]; +} + +/// A parsed/derived view bound to the exact artifact version it came from. +/// +/// A `StagedView` is valid only while the workspace's live head for +/// `artifact.artifact_id` still has `artifact.content_hash`. The moment the +/// artifact changes, every view bound to the old hash is stale by +/// construction and is rejected fail-closed by +/// [`crate::WorkspaceState::validate`] / [`crate::WorkspaceState::execute`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StagedView { + /// Workspace-scoped monotonic view id (audit handle; not the binding — + /// the binding is `artifact`). + pub view_id: u64, + /// Which of ADR-318's five reference kinds this view is. + pub kind: ViewKind, + /// The exact artifact version (hash + revision) this view was derived + /// from. This is the ADR-318 binding. + pub artifact: ArtifactRef, + /// SHA-256 of the view's own derived payload (parser output, diff text, + /// approval record, ...), so the view's content is itself + /// content-addressed and receipt-referenceable. + pub payload_hash: ContentHash, +} diff --git a/crates/ruvector-staged-workspace/src/workspace.rs b/crates/ruvector-staged-workspace/src/workspace.rs new file mode 100644 index 000000000..3eace3406 --- /dev/null +++ b/crates/ruvector-staged-workspace/src/workspace.rs @@ -0,0 +1,265 @@ +//! Workspace state: artifact lineages, view staging, and the fail-closed +//! staleness gate (ADR-318 §Decision 3). + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::anchor::{AnchorReceipt, EvidenceGrade, TransitionAnchor, WorkspaceTransitionRecord}; +use crate::artifact::{ArtifactRef, ContentHash}; +use crate::view::{StagedView, ViewKind}; + +/// The bound-vs-head detail of a rejected view (boxed inside +/// [`WorkspaceError`] to keep the error type small on the `Ok` path). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ViewConflict { + /// Lineage the view referenced. + pub artifact_id: String, + /// The exact version the view was derived from. + pub bound: ArtifactRef, + /// The artifact's current live head. + pub head: ArtifactRef, +} + +/// Errors returned by [`WorkspaceState`]. Every variant is a hard rejection: +/// per ADR-318, staleness and binding violations are never soft warnings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceError { + /// The referenced artifact lineage does not exist in this workspace. + UnknownArtifact(String), + /// The view is bound to a content hash that is no longer the artifact's + /// live head — the ADR-318 §Decision 3 structural staleness rejection. + /// Any operation carrying this view must recompute against the head. + StaleView(Box), + /// The view's hash matches the live head but its revision id does not — + /// a corrupt or forged binding, rejected outright. + BindingMismatch(Box), + /// The ADR-312 anchor refused the transition record; per the + /// "no anchor, no transition" contract the commit was NOT applied. + AnchorRejected(String), +} + +impl core::fmt::Display for WorkspaceError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + WorkspaceError::UnknownArtifact(id) => write!(f, "unknown artifact lineage {id:?}"), + WorkspaceError::StaleView(c) => write!( + f, + "stale view of {:?}: bound to {} (rev {}), live head is {} (rev {}) — recompute required", + c.artifact_id, + c.bound.content_hash, + c.bound.revision_id, + c.head.content_hash, + c.head.revision_id + ), + WorkspaceError::BindingMismatch(c) => write!( + f, + "binding mismatch for {:?}: view claims (hash {}, rev {}), head is (hash {}, rev {})", + c.artifact_id, + c.bound.content_hash, + c.bound.revision_id, + c.head.content_hash, + c.head.revision_id + ), + WorkspaceError::AnchorRejected(msg) => { + write!(f, "anchor rejected transition (commit aborted): {msg}") + } + } + } +} + +impl std::error::Error for WorkspaceError {} + +/// One artifact's lineage: its live head and the full hash history. +#[derive(Debug, Clone)] +struct ArtifactLineage { + head: ArtifactRef, + /// Every committed hash, oldest first; last entry equals `head.content_hash`. + history: Vec, +} + +/// The workspace: artifact lineages plus the validation gate that makes +/// stale views structurally invalid. +/// +/// Generic over the [`TransitionAnchor`] so callers (WP15's acceptance +/// harness, RVF/RVM integrations) can supply a real ADR-312/322C anchor; +/// defaults are available via [`WorkspaceState::with_noop_anchor`]. +pub struct WorkspaceState { + anchor: A, + lineages: HashMap, + /// (record, receipt) audit log of every anchored transition. + transitions: Vec<(WorkspaceTransitionRecord, AnchorReceipt)>, + next_sequence: u64, + next_view_id: u64, +} + +impl WorkspaceState { + /// Workspace with the in-process [`crate::anchor::NoopAnchor`]. + pub fn with_noop_anchor() -> Self { + Self::new(crate::anchor::NoopAnchor) + } +} + +impl WorkspaceState { + /// Create a workspace that anchors every transition through `anchor`. + pub fn new(anchor: A) -> Self { + Self { + anchor, + lineages: HashMap::new(), + transitions: Vec::new(), + next_sequence: 0, + next_view_id: 0, + } + } + + /// Commit a write of `content` to `artifact_id`, assigning a content + /// hash and the lineage's next revision id (ADR-318 §Decision 1). + /// + /// Committing bytes identical to the live head is a no-op returning the + /// existing head (no revision bump, no anchor record): the version did + /// not change, so no view becomes stale. + /// + /// The transition record is pushed through the ADR-312 anchor **before** + /// the workspace mutates; if the anchor rejects, the commit is aborted + /// and the lineage is unchanged ("no anchor, no transition"). + pub fn commit( + &mut self, + artifact_id: &str, + content: &[u8], + actor_id: &str, + ) -> Result { + let new_hash = ContentHash::of(content); + let prior = self.lineages.get(artifact_id).map(|l| l.head.clone()); + if let Some(ref head) = prior { + if head.content_hash == new_hash { + return Ok(head.clone()); + } + } + let new_revision = prior.as_ref().map_or(1, |h| h.revision_id + 1); + let record = WorkspaceTransitionRecord { + sequence: self.next_sequence, + timestamp_ns: now_ns(), + artifact_id: artifact_id.to_string(), + prior_hash: prior.as_ref().map(|h| h.content_hash), + prior_revision: prior.as_ref().map(|h| h.revision_id), + new_hash, + new_revision, + actor_id: actor_id.to_string(), + // The workspace derived new_hash from the artifact's actual + // bytes above — recomputed, not asserted. + evidence_grade: EvidenceGrade::Recomputed, + }; + let receipt = self + .anchor + .anchor(&record) + .map_err(|e| WorkspaceError::AnchorRejected(e.0))?; + // Anchor accepted: apply the mutation. + self.next_sequence += 1; + let head = ArtifactRef { + artifact_id: artifact_id.to_string(), + content_hash: new_hash, + revision_id: new_revision, + }; + let lineage = self + .lineages + .entry(artifact_id.to_string()) + .or_insert_with(|| ArtifactLineage { + head: head.clone(), + history: Vec::new(), + }); + lineage.head = head.clone(); + lineage.history.push(new_hash); + self.transitions.push((record, receipt)); + Ok(head) + } + + /// The artifact's live head (exact current version), if it exists. + pub fn head(&self, artifact_id: &str) -> Option<&ArtifactRef> { + self.lineages.get(artifact_id).map(|l| &l.head) + } + + /// Derive a view of `artifact_id`'s **current** version, binding it to + /// the head's exact hash + revision (ADR-318 §Decision 2). `payload` is + /// the view's own derived content (parser output, diff text, ...). + pub fn stage_view( + &mut self, + kind: ViewKind, + artifact_id: &str, + payload: &[u8], + ) -> Result { + let head = self + .lineages + .get(artifact_id) + .map(|l| l.head.clone()) + .ok_or_else(|| WorkspaceError::UnknownArtifact(artifact_id.to_string()))?; + let view = StagedView { + view_id: self.next_view_id, + kind, + artifact: head, + payload_hash: ContentHash::of(payload), + }; + self.next_view_id += 1; + Ok(view) + } + + /// Validate that `view` is still current: its bound content hash must + /// equal the artifact's live head hash (and the revision ids must + /// agree). Any mismatch is a hard rejection (ADR-318 §Decision 3). + pub fn validate(&self, view: &StagedView) -> Result<(), WorkspaceError> { + let id = &view.artifact.artifact_id; + let head = self + .lineages + .get(id) + .map(|l| &l.head) + .ok_or_else(|| WorkspaceError::UnknownArtifact(id.clone()))?; + let hash_ok = view.artifact.content_hash == head.content_hash; + let rev_ok = view.artifact.revision_id == head.revision_id; + match (hash_ok, rev_ok) { + (true, true) => Ok(()), + (false, _) => Err(WorkspaceError::StaleView(Box::new(ViewConflict { + artifact_id: id.clone(), + bound: view.artifact.clone(), + head: head.clone(), + }))), + (true, false) => Err(WorkspaceError::BindingMismatch(Box::new(ViewConflict { + artifact_id: id.clone(), + bound: view.artifact.clone(), + head: head.clone(), + }))), + } + } + + /// Run `op` only if `view` is still current — the fail-closed gate. + /// + /// If validation fails, `op` is **never invoked** and the error is + /// returned: an operation carrying a stale view cannot execute + /// (ADR-318 §Decision 3, "enforced structurally at read time"). + pub fn execute( + &self, + view: &StagedView, + op: impl FnOnce(&ArtifactRef) -> T, + ) -> Result { + self.validate(view)?; + // `validate` guaranteed the lineage exists and matches. + let head = self + .head(&view.artifact.artifact_id) + .expect("validated lineage must exist"); + Ok(op(head)) + } + + /// Audit log of every anchored transition, oldest first. + pub fn transitions(&self) -> &[(WorkspaceTransitionRecord, AnchorReceipt)] { + &self.transitions + } + + /// Full committed hash history for an artifact, oldest first. + pub fn history(&self, artifact_id: &str) -> Option<&[ContentHash]> { + self.lineages.get(artifact_id).map(|l| l.history.as_slice()) + } +} + +fn now_ns() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) +} diff --git a/crates/ruvector-staged-workspace/tests/invariant.rs b/crates/ruvector-staged-workspace/tests/invariant.rs new file mode 100644 index 000000000..2c05152d2 --- /dev/null +++ b/crates/ruvector-staged-workspace/tests/invariant.rs @@ -0,0 +1,234 @@ +//! ADR-318 invariant tests: hash binding round-trips, mutation invalidates +//! bound views, stale-view operations fail closed, current-view operations +//! succeed, and anchor rejection aborts commits. +//! +//! Benchmark note (preprint-reproduction rule): StagedWorkspace +//! (arXiv:2608.18050) reports +8.3-12.1pp OfficeQA Pass@1 for its own +//! models/implementation. That figure is NOT asserted by any test here; the +//! program's own delta on an internal OfficeQA-equivalent task set is a +//! future `research-gate` benchmark deliverable (ADR-318 §Decision 5). + +use std::cell::Cell; + +use ruvector_staged_workspace::{ + ArtifactRef, ContentHash, EvidenceGrade, RejectingAnchor, StagedView, ViewKind, WorkspaceError, + WorkspaceState, TRANSITION_DOMAIN, +}; + +const DOC: &str = "reports/q3.md"; + +// ── 1. Hash binding round-trips ───────────────────────────────────────────── + +#[test] +fn commit_binds_content_hash_and_revision() { + let mut ws = WorkspaceState::with_noop_anchor(); + let head = ws.commit(DOC, b"v1 bytes", "agent-a").unwrap(); + // The recorded hash is exactly SHA-256 of the committed bytes, + // independently recomputable. + assert_eq!(head.content_hash, ContentHash::of(b"v1 bytes")); + assert_eq!(head.revision_id, 1); + assert_eq!(ws.head(DOC), Some(&head)); +} + +#[test] +fn staged_view_serde_round_trip_preserves_binding() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + let view = ws + .stage_view(ViewKind::ParserResult, DOC, b"parsed") + .unwrap(); + let json = serde_json::to_string(&view).unwrap(); + let back: StagedView = serde_json::from_str(&json).unwrap(); + assert_eq!(back, view); + // The deserialized view still validates against the live workspace. + ws.validate(&back).unwrap(); +} + +#[test] +fn identical_content_commit_is_idempotent() { + let mut ws = WorkspaceState::with_noop_anchor(); + let first = ws.commit(DOC, b"same", "agent-a").unwrap(); + let view = ws.stage_view(ViewKind::Output, DOC, b"out").unwrap(); + let second = ws.commit(DOC, b"same", "agent-a").unwrap(); + // No version change: same ref, no new transition, view stays current. + assert_eq!(first, second); + assert_eq!(ws.transitions().len(), 1); + ws.validate(&view).unwrap(); +} + +#[test] +fn revisions_are_monotonic_and_history_is_kept() { + let mut ws = WorkspaceState::with_noop_anchor(); + for (i, content) in [b"a".as_ref(), b"b", b"c"].into_iter().enumerate() { + let head = ws.commit(DOC, content, "agent-a").unwrap(); + assert_eq!(head.revision_id, i as u64 + 1); + } + let history = ws.history(DOC).unwrap(); + assert_eq!(history.len(), 3); + assert_eq!(history[2], ContentHash::of(b"c")); +} + +// ── 2. Mutation invalidates every view bound to the old hash ──────────────── + +#[test] +fn mutation_invalidates_all_five_view_kinds() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + // Bind one view of every kind ADR-318 enumerates to revision 1. + let views: Vec = ViewKind::ALL + .iter() + .map(|&kind| ws.stage_view(kind, DOC, b"derived").unwrap()) + .collect(); + for v in &views { + ws.validate(v).unwrap(); // current before the mutation + } + // The workspace changes... + ws.commit(DOC, b"v2", "agent-b").unwrap(); + // ...and every view bound to the old hash is now structurally stale. + for v in &views { + match ws.validate(v) { + Err(WorkspaceError::StaleView(conflict)) => { + assert_eq!(conflict.bound.content_hash, ContentHash::of(b"v1")); + assert_eq!(conflict.head.content_hash, ContentHash::of(b"v2")); + assert_eq!(conflict.head.revision_id, 2); + } + other => panic!("expected StaleView for {:?}, got {other:?}", v.kind), + } + } +} + +// ── 3. Fail-closed proof: a stale-view operation never runs ───────────────── + +#[test] +fn stale_view_operation_fails_closed_op_never_runs() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + let view = ws + .stage_view(ViewKind::Approval, DOC, b"approve v1") + .unwrap(); + ws.commit(DOC, b"v2", "agent-b").unwrap(); + + let ran = Cell::new(false); + let result = ws.execute(&view, |_| ran.set(true)); + assert!(matches!(result, Err(WorkspaceError::StaleView(_)))); + // The operation body was never invoked: stale state is rejected before + // execution, not after. + assert!(!ran.get(), "operation ran against a stale view"); +} + +#[test] +fn current_view_operation_succeeds() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + let view = ws + .stage_view(ViewKind::ToolCall, DOC, b"tool input") + .unwrap(); + let seen_rev = ws.execute(&view, |head| head.revision_id).unwrap(); + assert_eq!(seen_rev, 1); +} + +#[test] +fn unknown_artifact_view_fails_closed() { + let ws = WorkspaceState::with_noop_anchor(); + let forged = StagedView { + view_id: 0, + kind: ViewKind::Diff, + artifact: ArtifactRef { + artifact_id: "no/such/artifact".into(), + content_hash: ContentHash::of(b"x"), + revision_id: 1, + }, + payload_hash: ContentHash::of(b"diff"), + }; + assert!(matches!( + ws.validate(&forged), + Err(WorkspaceError::UnknownArtifact(_)) + )); + let ran = Cell::new(false); + assert!(ws.execute(&forged, |_| ran.set(true)).is_err()); + assert!(!ran.get()); +} + +#[test] +fn matching_hash_with_wrong_revision_is_rejected_as_binding_mismatch() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + let mut view = ws.stage_view(ViewKind::Output, DOC, b"out").unwrap(); + view.artifact.revision_id = 99; // forged revision, correct hash + assert!(matches!( + ws.validate(&view), + Err(WorkspaceError::BindingMismatch(_)) + )); +} + +// ── 4. ADR-312 anchoring seam ─────────────────────────────────────────────── + +#[test] +fn anchor_rejection_aborts_commit_no_anchor_no_transition() { + let mut ws = WorkspaceState::new(RejectingAnchor { + reason: "ledger offline".into(), + }); + let err = ws.commit(DOC, b"v1", "agent-a").unwrap_err(); + assert!(matches!(err, WorkspaceError::AnchorRejected(ref m) if m == "ledger offline")); + // The mutation was NOT applied: no head, no history, no transitions. + assert!(ws.head(DOC).is_none()); + assert!(ws.history(DOC).is_none()); + assert!(ws.transitions().is_empty()); +} + +#[test] +fn transitions_carry_receipts_with_record_ids_and_evidence_grades() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + ws.commit(DOC, b"v2", "agent-b").unwrap(); + let transitions = ws.transitions(); + assert_eq!(transitions.len(), 2); + + let (create, create_receipt) = &transitions[0]; + assert_eq!(create.prior_hash, None); + assert_eq!(create.new_revision, 1); + // The workspace recomputed the hash from primary bytes. + assert_eq!(create.evidence_grade, EvidenceGrade::Recomputed); + // Receipt identity is the 322C-style SHA-256 of the canonical record. + assert_eq!(create_receipt.record_id, create.record_id()); + // NoopAnchor cannot claim a signature. + assert_eq!(create_receipt.grade, EvidenceGrade::TrustedAssertion); + + let (update, _) = &transitions[1]; + assert_eq!(update.prior_hash, Some(ContentHash::of(b"v1"))); + assert_eq!(update.prior_revision, Some(1)); + assert_eq!(update.new_hash, ContentHash::of(b"v2")); + assert_eq!(update.new_revision, 2); +} + +#[test] +fn record_id_is_deterministic_and_field_sensitive() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + ws.commit("other.md", b"v1", "agent-a").unwrap(); + let (a, _) = &ws.transitions()[0]; + let (b, _) = &ws.transitions()[1]; + assert_eq!( + a.record_id(), + a.record_id(), + "record_id must be deterministic" + ); + assert_ne!( + a.record_id(), + b.record_id(), + "records differing in any field must differ in identity" + ); +} + +#[test] +fn signing_preimage_is_domain_separated() { + let mut ws = WorkspaceState::with_noop_anchor(); + ws.commit(DOC, b"v1", "agent-a").unwrap(); + let (record, _) = &ws.transitions()[0]; + let preimage = record.signing_preimage(); + let domain = TRANSITION_DOMAIN.as_bytes(); + // ADR-322C scheme: domain || 0x00 || canonicalBytes. + assert_eq!(&preimage[..domain.len()], domain); + assert_eq!(preimage[domain.len()], 0x00); + assert_eq!(&preimage[domain.len() + 1..], &record.canonical_bytes()[..]); +}