mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-08-20 22:23:49 +00:00
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:
parent
f19babf0e9
commit
2450bc79f8
8 changed files with 925 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
21
crates/ruvector-staged-workspace/Cargo.toml
Normal file
21
crates/ruvector-staged-workspace/Cargo.toml
Normal 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 }
|
||||
206
crates/ruvector-staged-workspace/src/anchor.rs
Normal file
206
crates/ruvector-staged-workspace/src/anchor.rs
Normal 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()
|
||||
}))
|
||||
}
|
||||
}
|
||||
78
crates/ruvector-staged-workspace/src/artifact.rs
Normal file
78
crates/ruvector-staged-workspace/src/artifact.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
58
crates/ruvector-staged-workspace/src/lib.rs
Normal file
58
crates/ruvector-staged-workspace/src/lib.rs
Normal 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.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};
|
||||
60
crates/ruvector-staged-workspace/src/view.rs
Normal file
60
crates/ruvector-staged-workspace/src/view.rs
Normal 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,
|
||||
}
|
||||
265
crates/ruvector-staged-workspace/src/workspace.rs
Normal file
265
crates/ruvector-staged-workspace/src/workspace.rs
Normal 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)
|
||||
}
|
||||
234
crates/ruvector-staged-workspace/tests/invariant.rs
Normal file
234
crates/ruvector-staged-workspace/tests/invariant.rs
Normal 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()[..]);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue