A consumer (an editor surfacing whether a release would certify) needs to ask 'which gate blocks?' without holding the signing key. audit() is now public; audit().is_clear() is exactly certify()'s success condition. Co-Authored-By: Claude Opus 4.8 <[email protected]>
471 lines
19 KiB
Rust
471 lines
19 KiB
Rust
//! The **release certificate**: the single, total door to production.
|
|
//!
|
|
//! Mature engineering disciplines do not have *unaccounted* failure as an
|
|
//! option. A bridge does not ship because it "looks fine"; it ships because
|
|
//! its load path is proven, its margin is stamped, and the artifact carries
|
|
//! the evidence. Software has always kept a silent-ship lane open — "looks
|
|
//! right, push it." This crate closes it.
|
|
//!
|
|
//! The family already produces four independent verdicts over a unit of work:
|
|
//!
|
|
//! - **spec coverage** (`joule-code-spec::Coverage`) — every requirement and
|
|
//! constraint is implemented, no non-goal is cited;
|
|
//! - **contract discharge** (`joulecontract-proof`) — every declared
|
|
//! obligation is machine-proved;
|
|
//! - **two-stage review** (`joule-code-review::Approval`) — spec-compliance
|
|
//! and quality both approved by independent reviewers;
|
|
//! - **attested build** (`sandbox`) — the unit ran, isolated and metered, and
|
|
//! its build receipt verified.
|
|
//!
|
|
//! Separately, each is a gate an integrator *may* run. The lane between them
|
|
//! is the failure: nothing forces all four, so an artifact can reach
|
|
//! production having passed three and skipped one. The [`ReleaseCertificate`]
|
|
//! is the one object that makes the gate **total** — it certifies a release
|
|
//! **iff** all four gates are present and satisfied, and the only other
|
|
//! terminal state is a [`Refusal`] that names exactly which gates are missing
|
|
//! or failed. There is no third lane: no warning, no "mostly", no override.
|
|
//!
|
|
//! ## The irreducible residue, named on purpose
|
|
//!
|
|
//! No system eliminates the spec↔intent gap — you can prove the code meets
|
|
//! the spec, never that the spec is what was meant. That gap is not papered
|
|
//! over here: the certificate binds to the exact `spec` it was released
|
|
//! against (by content address) and to the spec-coverage gate, so the one
|
|
//! place a human must look — *is this the right spec?* — is named, signed,
|
|
//! and isolated. Everything else carries a proof.
|
|
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|
use joule_code_review::Approval;
|
|
use joule_code_schema::ContentId;
|
|
use joule_code_spec::Coverage;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Signature algorithm id for Ed25519 (matches `joule-code-schema`).
|
|
pub const ALG_ED25519: u8 = 0x01;
|
|
|
|
/// The four mandatory gates. A release names exactly these, each once.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum Gate {
|
|
/// Every requirement and constraint of the spec is implemented; no
|
|
/// non-goal is cited; no citation is invalid (`Coverage::is_complete`).
|
|
SpecCoverage,
|
|
/// Every declared contract obligation is machine-proved
|
|
/// (`ContractDischarge::discharged`).
|
|
ContractDischarge,
|
|
/// Two-stage review approved by independent reviewers
|
|
/// (`Approval::is_approved`).
|
|
Review,
|
|
/// The unit ran isolated + metered and its build receipt verified.
|
|
AttestedBuild,
|
|
}
|
|
|
|
impl Gate {
|
|
/// The canonical set, in order. A release must cover exactly this set.
|
|
pub fn all() -> [Gate; 4] {
|
|
[Gate::SpecCoverage, Gate::ContractDischarge, Gate::Review, Gate::AttestedBuild]
|
|
}
|
|
|
|
/// A stable human label.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Gate::SpecCoverage => "spec-coverage",
|
|
Gate::ContractDischarge => "contract-discharge",
|
|
Gate::Review => "review",
|
|
Gate::AttestedBuild => "attested-build",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One gate's verdict: whether it passed, and the content address of the
|
|
/// underlying artifact (in that standard's own scheme) so an auditor can
|
|
/// re-derive and re-check it with that standard's verifier.
|
|
///
|
|
/// `evidence` is an opaque address string because the gates span standards
|
|
/// with different hash families (joule-code uses BLAKE3; joulecontract uses
|
|
/// SHA-256). The certificate binds to each evidence string; it does not
|
|
/// re-hash across schemes.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct GateOutcome {
|
|
/// Which gate this is.
|
|
pub gate: Gate,
|
|
/// Whether the gate is satisfied. For the same-repo gates this is
|
|
/// *derived* from the real verdict type (see [`GateOutcome::from_coverage`]
|
|
/// / [`GateOutcome::from_approval`]) — it cannot be asserted. For the
|
|
/// cross-standard gates the caller supplies the verdict from that
|
|
/// standard's own check, bound to `evidence`.
|
|
pub satisfied: bool,
|
|
/// The underlying artifact's content address in its native scheme.
|
|
pub evidence: String,
|
|
}
|
|
|
|
impl GateOutcome {
|
|
/// Derive the spec-coverage outcome from a real [`Coverage`]. `satisfied`
|
|
/// is `coverage.is_complete()` — it cannot be claimed without the verdict.
|
|
pub fn from_coverage(coverage: &Coverage, spec_id: impl Into<String>) -> Self {
|
|
Self {
|
|
gate: Gate::SpecCoverage,
|
|
satisfied: coverage.is_complete(),
|
|
evidence: spec_id.into(),
|
|
}
|
|
}
|
|
|
|
/// Derive the review outcome from a real [`Approval`]. `satisfied` is
|
|
/// `approval.is_approved()`; `evidence` is the reviewed op's id.
|
|
pub fn from_approval(approval: &Approval, op_id: impl Into<String>) -> Self {
|
|
Self {
|
|
gate: Gate::Review,
|
|
satisfied: approval.is_approved(),
|
|
evidence: op_id.into(),
|
|
}
|
|
}
|
|
|
|
/// The contract-discharge outcome. `discharged` MUST come from
|
|
/// `joulecontract_proof::ContractDischarge::discharged()`; `evidence` is
|
|
/// the contract's content address. (Kept as a verdict+evidence pair rather
|
|
/// than a typed dependency because that standard hashes in a different
|
|
/// scheme — the certificate binds the address, not the type.)
|
|
pub fn contract(discharged: bool, contract_addr: impl Into<String>) -> Self {
|
|
Self { gate: Gate::ContractDischarge, satisfied: discharged, evidence: contract_addr.into() }
|
|
}
|
|
|
|
/// The attested-build outcome. `verified` MUST come from a re-verified
|
|
/// sandbox build receipt; `evidence` is the build id.
|
|
pub fn attested_build(verified: bool, build_id: impl Into<String>) -> Self {
|
|
Self { gate: Gate::AttestedBuild, satisfied: verified, evidence: build_id.into() }
|
|
}
|
|
}
|
|
|
|
/// A request to certify a release: what is being released, the spec it is
|
|
/// released against, and the four gate outcomes.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ReleaseRequest {
|
|
/// Signature algorithm id (`0x01` = Ed25519).
|
|
pub alg: u8,
|
|
/// Human label for the unit of work.
|
|
pub subject: String,
|
|
/// The released artifact's content address (e.g. the final build id or
|
|
/// op id) — the thing the certificate vouches for.
|
|
pub artifact: String,
|
|
/// The spec content address this release is judged against — the one
|
|
/// place a human's "is this the right spec?" lives.
|
|
pub spec: String,
|
|
/// The gate outcomes. Must cover exactly [`Gate::all`], each once.
|
|
pub outcomes: Vec<GateOutcome>,
|
|
/// The certifier's Ed25519 verifying key.
|
|
pub certifier: [u8; 32],
|
|
}
|
|
|
|
impl ReleaseRequest {
|
|
/// The release id: content address of the canonical request.
|
|
pub fn release_id(&self) -> Result<ContentId, ReleaseError> {
|
|
Ok(ContentId::of_canonical(self)?)
|
|
}
|
|
|
|
/// Partition the outcomes against the mandatory set: which gates are
|
|
/// missing, which are present-but-failed, and which appear more than once
|
|
/// (or are otherwise not in the canonical set). All three are grounds for
|
|
/// refusal — the gate is total, so a duplicate review cannot stand in for
|
|
/// a missing build.
|
|
///
|
|
/// Public so a consumer (e.g. an editor showing ship-readiness) can ask
|
|
/// "would this certify, and if not which gate blocks?" *without* holding
|
|
/// the signing key. `audit().is_clear()` is exactly the condition under
|
|
/// which [`certify`] would succeed.
|
|
pub fn audit(&self) -> Refusal {
|
|
let mut missing = Vec::new();
|
|
let mut failed = Vec::new();
|
|
let mut duplicated = Vec::new();
|
|
for gate in Gate::all() {
|
|
let hits: Vec<&GateOutcome> = self.outcomes.iter().filter(|o| o.gate == gate).collect();
|
|
match hits.len() {
|
|
0 => missing.push(gate),
|
|
1 => {
|
|
if !hits[0].satisfied {
|
|
failed.push(gate);
|
|
}
|
|
}
|
|
_ => duplicated.push(gate),
|
|
}
|
|
}
|
|
Refusal { missing, failed, duplicated }
|
|
}
|
|
}
|
|
|
|
/// The refusal terminal state: the release does NOT exist, and exactly why.
|
|
/// Empty vectors mean the request is certifiable.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Refusal {
|
|
/// Mandatory gates with no outcome in the request.
|
|
pub missing: Vec<Gate>,
|
|
/// Gates present but not satisfied.
|
|
pub failed: Vec<Gate>,
|
|
/// Gates appearing more than once (a gate may not be doubled to mask a
|
|
/// missing one).
|
|
pub duplicated: Vec<Gate>,
|
|
}
|
|
|
|
impl Refusal {
|
|
/// Whether this audit clears (nothing missing, failed, or duplicated).
|
|
pub fn is_clear(&self) -> bool {
|
|
self.missing.is_empty() && self.failed.is_empty() && self.duplicated.is_empty()
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Refusal {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
let names = |gs: &[Gate]| gs.iter().map(|g| g.label()).collect::<Vec<_>>().join(", ");
|
|
write!(f, "release refused —")?;
|
|
if !self.missing.is_empty() {
|
|
write!(f, " missing: [{}]", names(&self.missing))?;
|
|
}
|
|
if !self.failed.is_empty() {
|
|
write!(f, " failed: [{}]", names(&self.failed))?;
|
|
}
|
|
if !self.duplicated.is_empty() {
|
|
write!(f, " duplicated: [{}]", names(&self.duplicated))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Errors distinct from a refusal: a refusal is a *valid* "no", these are
|
|
/// malformed inputs or bad signatures.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum ReleaseError {
|
|
#[error("unsupported algorithm id {0:#04x}")]
|
|
BadAlg(u8),
|
|
#[error("signature verification failed")]
|
|
BadSignature,
|
|
#[error("schema: {0}")]
|
|
Schema(#[from] joule_code_schema::SchemaError),
|
|
}
|
|
|
|
/// A signed certificate that a release passed every gate. It exists only when
|
|
/// the audit cleared — there is no representation of a partial release.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct ReleaseCertificate {
|
|
/// The certified request.
|
|
pub request: ReleaseRequest,
|
|
/// Ed25519 signature (64 bytes) over `release_id ‖ artifact ‖ spec`.
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
impl ReleaseCertificate {
|
|
/// Preimage: `release_id ‖ artifact ‖ spec` — the certificate is bound to
|
|
/// the exact artifact and the exact spec it was judged against.
|
|
fn preimage(request: &ReleaseRequest) -> Result<Vec<u8>, ReleaseError> {
|
|
let id = request.release_id()?;
|
|
let mut pre = id.as_str().as_bytes().to_vec();
|
|
pre.extend_from_slice(request.artifact.as_bytes());
|
|
pre.extend_from_slice(request.spec.as_bytes());
|
|
Ok(pre)
|
|
}
|
|
|
|
/// Re-verify: signature valid AND the audit still clears. A certificate
|
|
/// whose outcomes were tampered to flip a gate fails here even if the
|
|
/// signature somehow held — totality is checked, not trusted.
|
|
pub fn verify(&self) -> Result<ContentId, ReleaseError> {
|
|
if self.request.alg != ALG_ED25519 {
|
|
return Err(ReleaseError::BadAlg(self.request.alg));
|
|
}
|
|
if !self.request.audit().is_clear() {
|
|
return Err(ReleaseError::BadSignature);
|
|
}
|
|
let pre = Self::preimage(&self.request)?;
|
|
let key = VerifyingKey::from_bytes(&self.request.certifier)
|
|
.map_err(|_| ReleaseError::BadSignature)?;
|
|
let sig_bytes: [u8; 64] =
|
|
self.sig.as_slice().try_into().map_err(|_| ReleaseError::BadSignature)?;
|
|
key.verify(&pre, &Signature::from_bytes(&sig_bytes))
|
|
.map_err(|_| ReleaseError::BadSignature)?;
|
|
self.request.release_id()
|
|
}
|
|
}
|
|
|
|
/// The total door. Certify a release **iff** every gate is present and
|
|
/// satisfied; otherwise return the [`Refusal`] that names exactly what is
|
|
/// missing, failed, or duplicated. Two terminal states, no third lane.
|
|
pub fn certify(
|
|
request: ReleaseRequest,
|
|
key: &SigningKey,
|
|
) -> Result<Result<ReleaseCertificate, Refusal>, ReleaseError> {
|
|
if request.alg != ALG_ED25519 {
|
|
return Err(ReleaseError::BadAlg(request.alg));
|
|
}
|
|
if request.certifier != key.verifying_key().to_bytes() {
|
|
return Err(ReleaseError::BadSignature);
|
|
}
|
|
let audit = request.audit();
|
|
if !audit.is_clear() {
|
|
return Ok(Err(audit));
|
|
}
|
|
let sig = key.sign(&ReleaseCertificate::preimage(&request)?);
|
|
Ok(Ok(ReleaseCertificate { request, sig: sig.to_bytes().to_vec() }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use joule_code_cargo::{Anchor, CodingOp, Edit};
|
|
use joule_code_review::{approval, Review, ReviewStage, ReviewVerdict, ALG_ED25519 as RALG};
|
|
use joule_code_schema::Tier;
|
|
use joule_code_spec::{cite, coverage, Clause, ClauseKind, Spec, ALG_ED25519 as SALG};
|
|
|
|
fn sk(s: u8) -> SigningKey {
|
|
SigningKey::from_bytes(&[s; 32])
|
|
}
|
|
|
|
/// A fully-satisfied request built from REAL spec/review verdicts.
|
|
fn satisfied_request(certifier: &SigningKey) -> ReleaseRequest {
|
|
let author = sk(1);
|
|
let reviewer = sk(2);
|
|
// a spec with one requirement, covered → complete
|
|
let spec = Spec {
|
|
alg: SALG,
|
|
intent: "persist".into(),
|
|
clauses: vec![Clause {
|
|
id: "C1".into(),
|
|
text: "todos persist".into(),
|
|
kind: ClauseKind::Requirement,
|
|
}],
|
|
parent: None,
|
|
author: author.verifying_key().to_bytes(),
|
|
}
|
|
.sign(&author)
|
|
.unwrap();
|
|
let spec_id = spec.verify().unwrap();
|
|
let op = CodingOp {
|
|
alg: 0x01,
|
|
prior: ContentId::of_bytes(b"t"),
|
|
edits: vec![Edit {
|
|
path: "s.rs".into(),
|
|
anchor: Anchor::ByteRange { start: 0, end: 0 },
|
|
replacement: vec![1],
|
|
}],
|
|
author: author.verifying_key().to_bytes(),
|
|
intent: "persist".into(),
|
|
tier: Tier::L3,
|
|
}
|
|
.sign(&author)
|
|
.unwrap();
|
|
let op_id = op.op.op_id().unwrap();
|
|
let citation = cite(&op, &spec, &["C1"], &author).unwrap();
|
|
let cov = coverage(&spec, std::slice::from_ref(&citation)).unwrap();
|
|
assert!(cov.is_complete());
|
|
|
|
// a real two-stage approval by an independent reviewer
|
|
let mk = |stage| {
|
|
Review {
|
|
alg: RALG,
|
|
op_id: op_id.clone(),
|
|
stage,
|
|
spec_id: Some(spec_id.clone()),
|
|
verdict: ReviewVerdict::Approve,
|
|
findings: vec![],
|
|
round: 1,
|
|
reviewer: reviewer.verifying_key().to_bytes(),
|
|
}
|
|
.sign(&reviewer)
|
|
.unwrap()
|
|
};
|
|
let reviews = [mk(ReviewStage::SpecCompliance), mk(ReviewStage::Quality)];
|
|
let ap = approval(&op, &reviews);
|
|
assert!(ap.is_approved());
|
|
|
|
ReleaseRequest {
|
|
alg: ALG_ED25519,
|
|
subject: "todo store".into(),
|
|
artifact: "build:abc".into(),
|
|
spec: spec_id.as_str().to_string(),
|
|
outcomes: vec![
|
|
GateOutcome::from_coverage(&cov, spec_id.as_str()),
|
|
GateOutcome::from_approval(&ap, op_id.as_str()),
|
|
GateOutcome::contract(true, "sha2-256:deadbeef"),
|
|
GateOutcome::attested_build(true, "build:abc"),
|
|
],
|
|
certifier: certifier.verifying_key().to_bytes(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn all_four_gates_satisfied_certifies_and_verifies() {
|
|
let key = sk(9);
|
|
let req = satisfied_request(&key);
|
|
let cert = certify(req, &key).unwrap().expect("all gates pass");
|
|
cert.verify().expect("certificate verifies");
|
|
}
|
|
|
|
#[test]
|
|
fn a_failed_gate_is_a_refusal_naming_it_not_a_certificate() {
|
|
let key = sk(9);
|
|
let mut req = satisfied_request(&key);
|
|
// flip the build gate to failed
|
|
req.outcomes.retain(|o| o.gate != Gate::AttestedBuild);
|
|
req.outcomes.push(GateOutcome::attested_build(false, "build:abc"));
|
|
let refusal = certify(req, &key).unwrap().expect_err("must refuse");
|
|
assert_eq!(refusal.failed, vec![Gate::AttestedBuild]);
|
|
assert!(refusal.to_string().contains("attested-build"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_gate_cannot_be_certified() {
|
|
let key = sk(9);
|
|
let mut req = satisfied_request(&key);
|
|
req.outcomes.retain(|o| o.gate != Gate::ContractDischarge);
|
|
let refusal = certify(req, &key).unwrap().expect_err("three gates is not enough");
|
|
assert_eq!(refusal.missing, vec![Gate::ContractDischarge]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_doubled_gate_cannot_mask_a_missing_one() {
|
|
let key = sk(9);
|
|
let mut req = satisfied_request(&key);
|
|
// drop the contract gate, add a second review in its place
|
|
req.outcomes.retain(|o| o.gate != Gate::ContractDischarge);
|
|
req.outcomes.push(GateOutcome::from_approval(
|
|
&Approval { spec_compliance: None, quality: None, void: 0, rounds: 0 },
|
|
"op",
|
|
));
|
|
// the extra review is unsatisfied AND doubled; contract is missing
|
|
let refusal = certify(req, &key).unwrap().expect_err("doubling cannot mask");
|
|
assert!(refusal.missing.contains(&Gate::ContractDischarge));
|
|
assert!(refusal.duplicated.contains(&Gate::Review));
|
|
}
|
|
|
|
#[test]
|
|
fn a_tampered_certificate_fails_totality_recheck() {
|
|
let key = sk(9);
|
|
let req = satisfied_request(&key);
|
|
let mut cert = certify(req, &key).unwrap().unwrap();
|
|
// flip a satisfied gate to failed AFTER signing
|
|
for o in &mut cert.request.outcomes {
|
|
if o.gate == Gate::ContractDischarge {
|
|
o.satisfied = false;
|
|
}
|
|
}
|
|
assert!(cert.verify().is_err()); // audit no longer clears
|
|
}
|
|
|
|
#[test]
|
|
fn coverage_that_is_incomplete_does_not_satisfy_the_spec_gate() {
|
|
let author = sk(1);
|
|
// an uncovered requirement → coverage incomplete → gate fails
|
|
let spec = Spec {
|
|
alg: SALG,
|
|
intent: "x".into(),
|
|
clauses: vec![Clause {
|
|
id: "C1".into(),
|
|
text: "unimplemented".into(),
|
|
kind: ClauseKind::Requirement,
|
|
}],
|
|
parent: None,
|
|
author: author.verifying_key().to_bytes(),
|
|
}
|
|
.sign(&author)
|
|
.unwrap();
|
|
let cov = coverage(&spec, &[]).unwrap();
|
|
let outcome = GateOutcome::from_coverage(&cov, spec.verify().unwrap().as_str());
|
|
assert!(!outcome.satisfied); // derived from the real verdict, not asserted
|
|
}
|
|
}
|