The field converged on spec-driven development with two-stage review (Superpowers, spec-kit, Amplifier), but every artifact in that consensus is prompt-enforced testimony. These three crates make the methodology checkable, in the family's house style (canonical det-CBOR, content addresses, Ed25519 over id ‖ context preimages): - joule-code-spec: the Spec artifact — intent + individually addressable clauses (requirements, constraints, and first-class NON-GOALS), signed and content-addressed, revisions chained by parent. A signed Citation binds a coding op to the clauses it claims to implement (the op stays wire-identical; traceability is additive, like the receipt). coverage() is the deterministic audit: open obligations, cited non-goals (a flag, not a pass), and invalid citations — recorded, never silently dropped. Completes the chain spec → clause → op → receipt. - joule-code-review: two-stage review (spec-compliance, then quality) as signed rounds. Separation of duties is ENFORCED, not advised: a review whose reviewer key equals the op author's key fails verification — self-approval is structurally void. RequestChanges without findings is malformed; approval() audits the latest verified round per stage, with rejected rounds kept on the record (the cost of convergence is part of the artifact). - joulecontract-proof: vericoding for the contract — each obligation with an explicit Formalization (statement + grounding tag; this crate NEVER invents one) becomes a ProofGoal citing the contract's content address and runs the JouleProof cascade. Per-obligation ledger: Proved (full CascadeResult, cache flywheel included) / Unproved (attempts + joules on record) / Unformalized (demoted, never dropped). discharged() is true only when every obligation proved — a contract is not "mostly" discharged. 16 new tests across the three crates; clippy clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
//! **Two-stage review as a signed artifact** — the methodology layer's review
|
|
//! discipline, made checkable instead of promised.
|
|
//!
|
|
//! The converged agentic-dev practice reviews every change twice: once for
|
|
//! **spec compliance** (did it do everything it was supposed to and nothing
|
|
//! else) and once for **quality** (does it meet the bar), with fresh
|
|
//! reviewers per round so no reviewer inherits the previous round's fatigue
|
|
//! or sunk cost. All of that lives in prompts today — nothing stops an agent
|
|
//! from approving its own work, and "it was reviewed" is testimony.
|
|
//!
|
|
//! This crate makes the discipline structural, in the family's house style:
|
|
//!
|
|
//! - a [`Review`] is a signed, content-addressed verdict by one reviewer on
|
|
//! one coding op, at one [`ReviewStage`], in one numbered round;
|
|
//! `RequestChanges` without findings is malformed — a rejection must say
|
|
//! why;
|
|
//! - **separation of duties is enforced, not advised**: a review whose
|
|
//! reviewer key equals the op's author key fails verification — the
|
|
//! competing-mandates rule the methodology people state as a guideline is
|
|
//! a signature check here;
|
|
//! - [`approval`] is the deterministic audit: an op is approved iff both
|
|
//! stages' **latest round** is an approval from a verified, independent
|
|
//! reviewer. Earlier rejected rounds stay on the record — the history of
|
|
//! how many rounds it took is part of the artifact, never overwritten.
|
|
//!
|
|
//! What this crate does not claim: that the reviewer had fresh context, or
|
|
//! good judgment. Those stay operational. What it proves: who reviewed what,
|
|
//! at which stage, in what order, with what verdict — and that none of them
|
|
//! was the author grading their own homework.
|
|
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|
use joule_code_cargo::SignedOp;
|
|
use joule_code_schema::ContentId;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Signature algorithm id for Ed25519 (matches `joule-code-schema`).
|
|
pub const ALG_ED25519: u8 = 0x01;
|
|
|
|
/// Errors from review construction, signing, or verification.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ReviewError {
|
|
#[error("unsupported algorithm id {0:#04x}")]
|
|
BadAlg(u8),
|
|
#[error("a spec-compliance review must cite the spec it checked against")]
|
|
MissingSpec,
|
|
#[error("request-changes must carry at least one finding")]
|
|
NoFindings,
|
|
#[error("an approval must not carry findings (write them, then request changes)")]
|
|
ApproveWithFindings,
|
|
#[error("the reviewer is the op's author — self-review is structurally void")]
|
|
SelfReview,
|
|
#[error("signature verification failed")]
|
|
BadSignature,
|
|
#[error("schema: {0}")]
|
|
Schema(#[from] joule_code_schema::SchemaError),
|
|
#[error("cargo: {0}")]
|
|
Cargo(#[from] joule_code_cargo::CargoError),
|
|
}
|
|
|
|
/// The two stages, in the order they gate.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum ReviewStage {
|
|
/// Did the op implement what was specified — everything, and nothing else.
|
|
SpecCompliance,
|
|
/// Does the change meet the quality bar.
|
|
Quality,
|
|
}
|
|
|
|
/// The verdict of one review round.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum ReviewVerdict {
|
|
Approve,
|
|
RequestChanges,
|
|
}
|
|
|
|
/// One signed review round (unsigned form).
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Review {
|
|
/// Signature algorithm id (`0x01` = Ed25519).
|
|
pub alg: u8,
|
|
/// The op under review.
|
|
pub op_id: ContentId,
|
|
/// Which gate this round belongs to.
|
|
pub stage: ReviewStage,
|
|
/// The spec checked against — required for [`ReviewStage::SpecCompliance`].
|
|
pub spec_id: Option<ContentId>,
|
|
/// The verdict.
|
|
pub verdict: ReviewVerdict,
|
|
/// Why changes are requested. Non-empty iff `RequestChanges`.
|
|
pub findings: Vec<String>,
|
|
/// Round number within this op+stage, starting at 1. Later rounds
|
|
/// supersede earlier ones; earlier ones remain on the record.
|
|
pub round: u32,
|
|
/// The reviewer's Ed25519 verifying key.
|
|
pub reviewer: [u8; 32],
|
|
}
|
|
|
|
impl Review {
|
|
/// Structural validation.
|
|
pub fn validate(&self) -> Result<(), ReviewError> {
|
|
if self.alg != ALG_ED25519 {
|
|
return Err(ReviewError::BadAlg(self.alg));
|
|
}
|
|
if self.stage == ReviewStage::SpecCompliance && self.spec_id.is_none() {
|
|
return Err(ReviewError::MissingSpec);
|
|
}
|
|
match self.verdict {
|
|
ReviewVerdict::RequestChanges if self.findings.is_empty() => {
|
|
Err(ReviewError::NoFindings)
|
|
}
|
|
ReviewVerdict::Approve if !self.findings.is_empty() => {
|
|
Err(ReviewError::ApproveWithFindings)
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// The review id: content address of the canonical form.
|
|
pub fn review_id(&self) -> Result<ContentId, ReviewError> {
|
|
self.validate()?;
|
|
Ok(ContentId::of_canonical(self)?)
|
|
}
|
|
|
|
/// Preimage: `review_id ‖ op_id` — the verdict is bound to exactly this op.
|
|
fn preimage(&self) -> Result<Vec<u8>, ReviewError> {
|
|
let id = self.review_id()?;
|
|
let mut pre = id.as_str().as_bytes().to_vec();
|
|
pre.extend_from_slice(self.op_id.as_str().as_bytes());
|
|
Ok(pre)
|
|
}
|
|
|
|
/// Sign with the reviewer's key.
|
|
pub fn sign(self, key: &SigningKey) -> Result<SignedReview, ReviewError> {
|
|
let sig = key.sign(&self.preimage()?);
|
|
Ok(SignedReview { review: self, sig: sig.to_bytes().to_vec() })
|
|
}
|
|
}
|
|
|
|
/// A review plus its reviewer's signature.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct SignedReview {
|
|
pub review: Review,
|
|
/// Ed25519 signature (64 bytes) over `review_id ‖ op_id`.
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
impl SignedReview {
|
|
/// Verify structure and signature, and — against the op — enforce the
|
|
/// separation of duties: the reviewer key must differ from the op's
|
|
/// author key, or the review is void no matter what it says.
|
|
pub fn verify(&self, op: &SignedOp) -> Result<ContentId, ReviewError> {
|
|
let op_id = op.op.op_id()?;
|
|
if self.review.op_id != op_id {
|
|
return Err(ReviewError::BadSignature);
|
|
}
|
|
if self.review.reviewer == op.op.author {
|
|
return Err(ReviewError::SelfReview);
|
|
}
|
|
let pre = self.review.preimage()?;
|
|
let key =
|
|
VerifyingKey::from_bytes(&self.review.reviewer).map_err(|_| ReviewError::BadSignature)?;
|
|
let sig_bytes: [u8; 64] =
|
|
self.sig.as_slice().try_into().map_err(|_| ReviewError::BadSignature)?;
|
|
key.verify(&pre, &Signature::from_bytes(&sig_bytes))
|
|
.map_err(|_| ReviewError::BadSignature)?;
|
|
self.review.review_id()
|
|
}
|
|
}
|
|
|
|
/// The deterministic approval audit for one op.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Approval {
|
|
/// The latest verified round per stage, if any: (round, verdict).
|
|
pub spec_compliance: Option<(u32, ReviewVerdict)>,
|
|
pub quality: Option<(u32, ReviewVerdict)>,
|
|
/// Reviews that failed verification (bad signature, wrong op,
|
|
/// self-review) — on the record, never silently dropped.
|
|
pub void: usize,
|
|
/// Total verified rounds across both stages — the cost-of-convergence
|
|
/// number (how many rounds it took is part of the story).
|
|
pub rounds: usize,
|
|
}
|
|
|
|
impl Approval {
|
|
/// Approved iff both stages' latest verified round is an approval.
|
|
pub fn is_approved(&self) -> bool {
|
|
matches!(self.spec_compliance, Some((_, ReviewVerdict::Approve)))
|
|
&& matches!(self.quality, Some((_, ReviewVerdict::Approve)))
|
|
}
|
|
}
|
|
|
|
/// Audit an op against its review record.
|
|
pub fn approval(op: &SignedOp, reviews: &[SignedReview]) -> Approval {
|
|
let mut spec: Option<(u32, ReviewVerdict)> = None;
|
|
let mut quality: Option<(u32, ReviewVerdict)> = None;
|
|
let mut void = 0usize;
|
|
let mut rounds = 0usize;
|
|
|
|
for signed in reviews {
|
|
if signed.verify(op).is_err() {
|
|
void += 1;
|
|
continue;
|
|
}
|
|
rounds += 1;
|
|
let slot = match signed.review.stage {
|
|
ReviewStage::SpecCompliance => &mut spec,
|
|
ReviewStage::Quality => &mut quality,
|
|
};
|
|
let supersedes = slot.map(|(round, _)| signed.review.round > round).unwrap_or(true);
|
|
if supersedes {
|
|
*slot = Some((signed.review.round, signed.review.verdict));
|
|
}
|
|
}
|
|
|
|
Approval { spec_compliance: spec, quality, void, rounds }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use joule_code_cargo::{Anchor, CodingOp, Edit};
|
|
use joule_code_schema::Tier;
|
|
|
|
fn sk(s: u8) -> SigningKey {
|
|
SigningKey::from_bytes(&[s; 32])
|
|
}
|
|
|
|
fn op_fixture(author: &SigningKey) -> SignedOp {
|
|
CodingOp {
|
|
alg: 0x01,
|
|
prior: ContentId::of_bytes(b"tree"),
|
|
edits: vec![Edit {
|
|
path: "src/lib.rs".into(),
|
|
anchor: Anchor::ByteRange { start: 0, end: 0 },
|
|
replacement: b"fn f() {}".to_vec(),
|
|
}],
|
|
author: author.verifying_key().to_bytes(),
|
|
intent: "add f".into(),
|
|
tier: Tier::L3,
|
|
}
|
|
.sign(author)
|
|
.expect("valid op signs")
|
|
}
|
|
|
|
fn review(
|
|
op: &SignedOp,
|
|
stage: ReviewStage,
|
|
verdict: ReviewVerdict,
|
|
findings: Vec<String>,
|
|
round: u32,
|
|
reviewer: &SigningKey,
|
|
) -> SignedReview {
|
|
Review {
|
|
alg: ALG_ED25519,
|
|
op_id: op.op.op_id().unwrap(),
|
|
stage,
|
|
spec_id: Some(ContentId::of_bytes(b"spec")),
|
|
verdict,
|
|
findings,
|
|
round,
|
|
reviewer: reviewer.verifying_key().to_bytes(),
|
|
}
|
|
.sign(reviewer)
|
|
.expect("valid review signs")
|
|
}
|
|
|
|
#[test]
|
|
fn self_review_is_structurally_void() {
|
|
let author = sk(1);
|
|
let op = op_fixture(&author);
|
|
let own = review(&op, ReviewStage::Quality, ReviewVerdict::Approve, vec![], 1, &author);
|
|
assert!(matches!(own.verify(&op), Err(ReviewError::SelfReview)));
|
|
// and the audit refuses to count it
|
|
let a = approval(&op, &[own]);
|
|
assert_eq!(a.void, 1);
|
|
assert!(!a.is_approved());
|
|
}
|
|
|
|
#[test]
|
|
fn approval_requires_both_stages() {
|
|
let author = sk(1);
|
|
let (r1, r2) = (sk(2), sk(3));
|
|
let op = op_fixture(&author);
|
|
let spec_ok =
|
|
review(&op, ReviewStage::SpecCompliance, ReviewVerdict::Approve, vec![], 1, &r1);
|
|
// spec compliance alone is not approval
|
|
assert!(!approval(&op, std::slice::from_ref(&spec_ok)).is_approved());
|
|
let quality_ok = review(&op, ReviewStage::Quality, ReviewVerdict::Approve, vec![], 1, &r2);
|
|
assert!(approval(&op, &[spec_ok, quality_ok]).is_approved());
|
|
}
|
|
|
|
#[test]
|
|
fn later_rounds_supersede_and_history_stays_on_record() {
|
|
let author = sk(1);
|
|
let (r1, r2, r3) = (sk(2), sk(3), sk(4));
|
|
let op = op_fixture(&author);
|
|
let rejected = review(
|
|
&op,
|
|
ReviewStage::Quality,
|
|
ReviewVerdict::RequestChanges,
|
|
vec!["unwrap() in the hot path".into()],
|
|
1,
|
|
&r1,
|
|
);
|
|
let fixed = review(&op, ReviewStage::Quality, ReviewVerdict::Approve, vec![], 2, &r2);
|
|
let spec_ok =
|
|
review(&op, ReviewStage::SpecCompliance, ReviewVerdict::Approve, vec![], 1, &r3);
|
|
let a = approval(&op, &[rejected, fixed, spec_ok]);
|
|
assert!(a.is_approved());
|
|
assert_eq!(a.rounds, 3); // the cost of convergence is part of the artifact
|
|
assert_eq!(a.quality, Some((2, ReviewVerdict::Approve)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_rejection_must_say_why_and_an_approval_must_not_nag() {
|
|
let author = sk(1);
|
|
let reviewer = sk(2);
|
|
let op = op_fixture(&author);
|
|
let mute = Review {
|
|
alg: ALG_ED25519,
|
|
op_id: op.op.op_id().unwrap(),
|
|
stage: ReviewStage::Quality,
|
|
spec_id: None,
|
|
verdict: ReviewVerdict::RequestChanges,
|
|
findings: vec![],
|
|
round: 1,
|
|
reviewer: reviewer.verifying_key().to_bytes(),
|
|
};
|
|
assert!(matches!(mute.validate(), Err(ReviewError::NoFindings)));
|
|
let nag = Review {
|
|
verdict: ReviewVerdict::Approve,
|
|
findings: vec!["fine I guess".into()],
|
|
..mute
|
|
};
|
|
assert!(matches!(nag.validate(), Err(ReviewError::ApproveWithFindings)));
|
|
}
|
|
|
|
#[test]
|
|
fn spec_compliance_must_cite_a_spec() {
|
|
let author = sk(1);
|
|
let reviewer = sk(2);
|
|
let op = op_fixture(&author);
|
|
let unanchored = Review {
|
|
alg: ALG_ED25519,
|
|
op_id: op.op.op_id().unwrap(),
|
|
stage: ReviewStage::SpecCompliance,
|
|
spec_id: None,
|
|
verdict: ReviewVerdict::Approve,
|
|
findings: vec![],
|
|
round: 1,
|
|
reviewer: reviewer.verifying_key().to_bytes(),
|
|
};
|
|
assert!(matches!(unanchored.validate(), Err(ReviewError::MissingSpec)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_review_for_a_different_op_does_not_transfer() {
|
|
let author = sk(1);
|
|
let reviewer = sk(2);
|
|
let op_a = op_fixture(&author);
|
|
let mut other = op_a.op.clone();
|
|
other.intent = "different op".into();
|
|
let op_b = other.sign(&author).unwrap();
|
|
let for_b = review(&op_b, ReviewStage::Quality, ReviewVerdict::Approve, vec![], 1, &reviewer);
|
|
assert!(for_b.verify(&op_a).is_err());
|
|
assert_eq!(approval(&op_a, &[for_b]).void, 1);
|
|
}
|
|
}
|