feat: StagedWorkspace content-hash state binding invariant (PIR WP16, ADR-318)

Implements the StagedWorkspace (arXiv:2608.18050) pattern as a RuV
invariant per ADR-318, as a new small crate ruvector-staged-workspace:

- ArtifactRef { content_hash, revision_id }: every artifact gets a
  SHA-256 content hash (reusing rvf-types' NIST-verified FIPS 180-4
  implementation -- the 322C contract hash, no new hash introduced) and
  a monotonic lineage-scoped revision id at write time.
- StagedView binds parser results, tool calls, diffs, approvals, and
  outputs (the five ADR-318 view kinds) to the exact artifact version
  they were derived from.
- WorkspaceState validates views against the live head and enforces the
  fail-closed invariant: an operation carrying a stale view is rejected
  before its body ever runs; hash-matching but revision-forged bindings
  are rejected as BindingMismatch.
- ADR-312 seam: every commit emits a WorkspaceTransitionRecord with a
  canonical byte encoding, SHA-256 record id (mirroring ADR-322C's
  receiptId derivation), a domain-separated signing preimage
  (ruv/staged-workspace-transition/v1 || 0x00 || canonicalBytes), and
  322C evidence grades, pushed through the TransitionAnchor trait; an
  anchor rejection aborts the commit (no anchor, no transition).
- 15 tests green (cargo test -p ruvector-staged-workspace); the paper's
  +8.3-12.1pp OfficeQA figure is NOT claimed -- internal benchmark is a
  future research-gate deliverable per ADR-318 SS Decision 5.

Refs ruvnet/RuVector#863, ruvnet/RuVector#837.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_012Jib2gQyJpqCoo2xYAbb4X
This commit is contained in:
ruv 2026-08-20 09:19:08 -04:00
parent f19babf0e9
commit 2450bc79f8
8 changed files with 925 additions and 0 deletions

View file

@ -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",

View file

@ -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 }

View file

@ -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<ContentHash>,
/// Revision id being superseded (`None` on create).
pub prior_revision: Option<u64>,
/// 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<u8> {
fn put_str(out: &mut Vec<u8>, 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<u8> {
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<AnchorReceipt, AnchorError>;
}
/// 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<AnchorReceipt, AnchorError> {
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<AnchorReceipt, AnchorError> {
Err(AnchorError(if self.reason.is_empty() {
"rejecting anchor".to_string()
} else {
self.reason.clone()
}))
}
}

View file

@ -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);
}
}

View file

@ -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.312.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};

View file

@ -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,
}

View file

@ -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<ViewConflict>),
/// The view's hash matches the live head but its revision id does not —
/// a corrupt or forged binding, rejected outright.
BindingMismatch(Box<ViewConflict>),
/// 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<ContentHash>,
}
/// 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<A: TransitionAnchor> {
anchor: A,
lineages: HashMap<String, ArtifactLineage>,
/// (record, receipt) audit log of every anchored transition.
transitions: Vec<(WorkspaceTransitionRecord, AnchorReceipt)>,
next_sequence: u64,
next_view_id: u64,
}
impl WorkspaceState<crate::anchor::NoopAnchor> {
/// Workspace with the in-process [`crate::anchor::NoopAnchor`].
pub fn with_noop_anchor() -> Self {
Self::new(crate::anchor::NoopAnchor)
}
}
impl<A: TransitionAnchor> WorkspaceState<A> {
/// 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<ArtifactRef, WorkspaceError> {
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<StagedView, WorkspaceError> {
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<T>(
&self,
view: &StagedView,
op: impl FnOnce(&ArtifactRef) -> T,
) -> Result<T, WorkspaceError> {
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)
}

View file

@ -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<StagedView> = 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()[..]);
}