joule-code-joulecontract: an obligation nothing checked is not discharged #307

Merged
dcharlot merged 1 commit from fix/joule-code-joulecontract-no-fabricated-discharge into main 2026-10-18 00:42:55 -04:00

View file

@ -18,7 +18,7 @@
//! composing them to JR-3+ with sealed evidence, a key log, and anchored
//! intervals is the deployment's verifier-of-record work, not this crate's.
use joule_code_cargo::CodingOp;
use joule_code_cargo::{CodingOp, SignedOp};
use joule_code_receipt::{Energy, Provenance};
use joule_code_schema::ContentId;
use joulecontract_receipt::{
@ -95,6 +95,77 @@ pub enum BindingError {
OutOfFrame { path: String },
#[error("contract addressing failed: {0}")]
Address(String),
/// The op's own id could not be derived, so no receipt can name the
/// operation it is about.
#[error("op id could not be derived: {0}")]
OpId(String),
}
/// Decide one declared obligation against what this binder actually holds.
///
/// Every arm either evaluates the obligation or reports it `Undischarged`
/// with the reason it was not evaluated. **There is no default `Closed`.**
/// `Closed` is JouleContract's positive "this obligation was discharged", and
/// it used to be the fall-through for every Pre, Post and Inv obligation —
/// signed, attributed to a named `Deterministic` tier, with nothing having
/// evaluated them. The worst case was the Post obligation below: it states
/// the same property as the frame obligation, so a single receipt could carry
/// `Frame = Undischarged { "out-of-frame edit: deploy/secrets.env" }` and
/// `Post = Closed` for "every edit applied within the declared frame" — one
/// signature asserting both that an edit escaped the frame and that every
/// edit stayed inside it.
fn discharge_for(
signed: &SignedOp,
prior: &ContentId,
frame_result: &DischargeResult,
ob: &joulecontract_schema::Obligation,
) -> DischargeResult {
use joulecontract_schema::ObligationKind as Kind;
match (&ob.kind, ob.text.as_str()) {
// Decided by `frame_check`.
(Kind::Frame, _) => frame_result.clone(),
// The same property as the frame obligation, so it carries the same
// evidence rather than a second, contradictory verdict.
(Kind::Post, "every edit applied within the declared frame") => frame_result.clone(),
// The contract names the tree the op must build on. `SignedOp::verify`
// below binds the op to `prior`; this checks the *contract* was
// written for that same prior, so the two cannot disagree silently.
(Kind::Pre, text) if text.starts_with("op builds on prior tree ") => {
let expected = format!("op builds on prior tree {}", prior.0);
if text == expected {
DischargeResult::Closed
} else {
DischargeResult::Undischarged {
reason: format!("contract declares `{text}`, this receipt states prior {}", prior.0),
}
}
}
// Exactly what `SignedOp::verify` decides: it recomputes `op_id`,
// rebuilds `op_id ‖ prior`, checks the signature against `op.author`,
// and rejects replay onto a different base.
(Kind::Inv, "op signature verifies over op_id ‖ prior") => match signed.verify(prior) {
Ok(()) => DischargeResult::Closed,
Err(e) => DischargeResult::Undischarged {
reason: format!("signature does not verify over op_id ‖ prior: {e}"),
},
},
// Exactly what `CodingOp::validate` decides (SPEC §2: known alg,
// non-empty edits, ascending `(path, start)`, no overlap).
(Kind::Inv, "edits are ordered and pairwise disjoint") => match signed.op.validate() {
Ok(()) => DischargeResult::Closed,
Err(e) => DischargeResult::Undischarged { reason: e.to_string() },
},
// An obligation this binder does not recognise. A caller may declare
// any prose it likes; none of it is discharged by being written down.
(_, text) => DischargeResult::Undischarged {
reason: format!("not evaluated by this binder: {text}"),
},
}
}
/// Check every edit path against the contract's frame. A path is in-frame
@ -147,13 +218,33 @@ impl Binder {
/// set, not analyzed from the artifact. An out-of-frame op still gets a
/// receipt: the frame serializes `undischarged` with the offending path,
/// because a breach recorded honestly beats a breach driven underground.
/// Seal a receipt for one signed op against a contract.
///
/// Takes the **signed** op and the `prior` the verifier believes current,
/// because two of the contract's obligations are statements about exactly
/// those: the signature over `op_id ‖ prior`, and the tree the op builds
/// on. With an unsigned `CodingOp` and a caller-supplied `op_id` neither
/// could be decided — and both were nonetheless sealed as discharged.
///
/// The op id is **derived** from the op rather than supplied, so
/// `transition` always names the operation whose obligations were checked;
/// a caller-supplied id was trusted and never re-derived, so a receipt
/// could carry one op's frame verdict under another op's id.
///
/// # Errors
/// [`BindingError::OpId`] if the op's id cannot be derived (it is
/// structurally invalid), or [`BindingError::Address`] if the contract
/// cannot be addressed. A failing *obligation* is not an error: it is
/// recorded as `Undischarged` on the receipt, which is the point.
pub fn receipt_for_op(
&self,
contract: &Contract,
op: &CodingOp,
op_id: &ContentId,
signed: &SignedOp,
prior: &ContentId,
energy: &Energy,
) -> Result<Receipt, BindingError> {
let op: &CodingOp = &signed.op;
let op_id = op.op_id().map_err(|e| BindingError::OpId(e.to_string()))?;
let frame_result = match frame_check(contract, op) {
Ok(()) => DischargeResult::Closed,
Err(BindingError::OutOfFrame { path }) => DischargeResult::Undischarged {
@ -172,16 +263,13 @@ impl Binder {
.into_iter()
.map(|ob| {
let is_frame = ob.kind == joulecontract_schema::ObligationKind::Frame;
let result = discharge_for(signed, prior, &frame_result, &ob);
DischargeEntry {
obligation: ob,
tier: binding_tier.clone(),
tier_class: TierClass::Deterministic,
tier_operator_keys: self.tier_operator_keys.clone(),
result: if is_frame {
frame_result.clone()
} else {
DischargeResult::Closed
},
result,
mode: is_frame.then_some(FrameMode::Observed),
qualification: Some(self.qualification.clone()),
verifier_keys: self.verifier_keys.clone(),
@ -197,7 +285,7 @@ impl Binder {
contract: contract
.content_address()
.map_err(|e| BindingError::Address(e.to_string()))?,
transition: bridge_id(op_id),
transition: bridge_id(&op_id),
generator: self.generator.clone(),
discharge,
execution: vec![ContentAddress::of_bytes(
@ -240,24 +328,44 @@ mod tests {
ContentAddress::of_bytes(label.as_bytes())
}
fn op(paths: &[&str]) -> (CodingOp, ContentId) {
fn author_key() -> ed25519_dalek::SigningKey {
ed25519_dalek::SigningKey::from_bytes(&[42u8; 32])
}
/// The tree `contract()` declares its op must build on.
fn prior() -> ContentId {
ContentId::of_bytes(b"prior tree")
}
/// A real signed op. `author` is the signing key's own verifying key, and
/// the edits are sorted into canonical `(path, start)` order, because
/// `op_id` runs `validate` and `sign` runs `op_id` — an op that is not
/// canonical has no id and therefore no signature.
///
/// This replaces a fixture that set `author: [7u8; 32]` (not a key) and
/// returned `ContentId::of_bytes(b"the op canonical bytes")` as the id —
/// a constant unrelated to the op, which is what let the untrusted
/// `op_id` parameter go unnoticed.
fn op(paths: &[&str]) -> SignedOp {
let key = author_key();
let mut sorted: Vec<&&str> = paths.iter().collect();
sorted.sort();
let op = CodingOp {
alg: 0x01,
prior: ContentId::of_bytes(b"prior tree"),
edits: paths
.iter()
prior: prior(),
edits: sorted
.into_iter()
.map(|p| Edit {
path: p.to_string(),
path: (*p).to_string(),
anchor: Anchor::ByteRange { start: 0, end: 4 },
replacement: b"new!".to_vec(),
})
.collect(),
author: [7u8; 32],
author: key.verifying_key().to_bytes(),
intent: "rename the settlement index field".into(),
tier: Tier::L1,
};
let id = ContentId::of_bytes(b"the op canonical bytes");
(op, id)
op.sign(&key).expect("a canonical op signs")
}
fn binder() -> Binder {
@ -323,13 +431,13 @@ mod tests {
#[test]
fn in_frame_op_discharges_and_validates_to_jr2() {
let c = contract(&["src/", "Cargo.toml"]);
let (op, id) = op(&["src/lib.rs", "Cargo.toml"]);
let signed = op(&["src/lib.rs", "Cargo.toml"]);
let energy = Energy {
measured_uj: Some(1_200),
estimated_uj: 1_000,
provenance: Provenance::HwShunt,
};
let r = binder().receipt_for_op(&c, &op, &id, &energy).unwrap();
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
let v = validate(&c, &r).unwrap();
assert_eq!(v.max_profile.rigor, Rigor::Jr2);
assert_eq!(v.max_profile.energy, EnergyClass::Metered);
@ -341,9 +449,9 @@ mod tests {
#[test]
fn out_of_frame_edit_is_caught_at_the_boundary() {
let c = contract(&["src/"]);
let (op, id) = op(&["src/lib.rs", "deploy/secrets.env"]);
let signed = op(&["src/lib.rs", "deploy/secrets.env"]);
assert_eq!(
frame_check(&c, &op),
frame_check(&c, &signed.op),
Err(BindingError::OutOfFrame {
path: "deploy/secrets.env".into()
})
@ -355,7 +463,7 @@ mod tests {
estimated_uj: 900,
provenance: Provenance::ModelBased,
};
let r = binder().receipt_for_op(&c, &op, &id, &energy).unwrap();
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
let v = validate(&c, &r).unwrap();
assert!(v.demotions.iter().any(|d| matches!(
d,
@ -364,12 +472,153 @@ mod tests {
assert_eq!(v.max_profile.rigor, Rigor::Jr1, "undischarged frame caps rigor");
}
/// The contradiction this fix removes. The Post obligation states the same
/// property as the Frame obligation, so one receipt cannot carry
/// "an edit escaped the frame" and "every edit stayed inside it" at once.
#[test]
fn the_post_obligation_carries_the_frame_verdict() {
let c = contract(&["src/"]);
let signed = op(&["src/lib.rs", "deploy/secrets.env"]);
let energy = Energy {
measured_uj: Some(900),
estimated_uj: 900,
provenance: Provenance::HwShunt,
};
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
let post = r
.discharge
.iter()
.find(|d| d.obligation.text == "every edit applied within the declared frame")
.expect("the contract declares it");
let frame = r
.discharge
.iter()
.find(|d| d.obligation.kind == joulecontract_schema::ObligationKind::Frame)
.expect("the contract declares it");
assert_eq!(post.result, frame.result, "same property, same verdict");
assert!(
matches!(&post.result, DischargeResult::Undischarged { reason } if reason.contains("deploy/secrets.env")),
"got {:?}",
post.result
);
}
/// The signature obligation is decided, not assumed. A tampered signature
/// leaves it undischarged instead of sealing "verifies" over bytes that
/// do not.
#[test]
fn the_signature_obligation_is_decided_not_assumed() {
let c = contract(&["src/"]);
let mut signed = op(&["src/lib.rs"]);
let energy = Energy {
measured_uj: Some(900),
estimated_uj: 900,
provenance: Provenance::HwShunt,
};
// Intact: the obligation closes on a real check.
let good = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
let sig_of = |r: &Receipt| {
r.discharge
.iter()
.find(|d| d.obligation.text == "op signature verifies over op_id \u{2016} prior")
.map(|d| d.result.clone())
.expect("the contract declares it")
};
assert_eq!(sig_of(&good), DischargeResult::Closed);
// Tampered: undischarged, with the reason on the record.
*signed.sig.last_mut().expect("signature bytes") ^= 1;
let bad = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
assert!(
matches!(sig_of(&bad), DischargeResult::Undischarged { .. }),
"got {:?}",
sig_of(&bad)
);
}
/// A caller may declare any prose it likes; none of it is discharged by
/// being written down. The fall-through used to be `Closed`.
#[test]
fn an_unrecognised_obligation_is_never_closed() {
let mut c = contract(&["src/"]);
c.post.push("the author was well rested".into());
let signed = op(&["src/lib.rs"]);
let energy = Energy {
measured_uj: Some(900),
estimated_uj: 900,
provenance: Provenance::HwShunt,
};
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
let entry = r
.discharge
.iter()
.find(|d| d.obligation.text == "the author was well rested")
.expect("declared");
assert!(
matches!(&entry.result, DischargeResult::Undischarged { reason } if reason.contains("not evaluated")),
"got {:?}",
entry.result
);
}
/// The receipt names the operation whose obligations it checked: the id is
/// derived from the op, not supplied beside it.
#[test]
fn the_transition_names_the_op_it_checked() {
let c = contract(&["src/"]);
let signed = op(&["src/lib.rs"]);
let energy = Energy {
measured_uj: Some(900),
estimated_uj: 900,
provenance: Provenance::HwShunt,
};
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
assert_eq!(r.transition, bridge_id(&signed.op.op_id().unwrap()));
}
/// A contract written for a different tree does not have its precondition
/// discharged by a receipt that states another prior.
#[test]
fn a_contract_for_a_different_prior_leaves_the_precondition_open() {
let c = contract_for_op(
"rename the settlement index field",
&ContentId::of_bytes(b"some other tree"),
&["src/".to_string()],
0.01,
key("enb"),
None,
Rigor::Jr2,
);
let signed = op(&["src/lib.rs"]);
let energy = Energy {
measured_uj: Some(900),
estimated_uj: 900,
provenance: Provenance::HwShunt,
};
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
let pre = r
.discharge
.iter()
.find(|d| d.obligation.kind == joulecontract_schema::ObligationKind::Pre)
.expect("declared");
assert!(
matches!(&pre.result, DischargeResult::Undischarged { .. }),
"got {:?}",
pre.result
);
}
#[test]
fn estimator_energy_is_unaccounted_never_promoted() {
let c = contract(&["src/"]);
let (op, id) = op(&["src/lib.rs"]);
let signed = op(&["src/lib.rs"]);
let energy = Energy::from_tokens(100, 10);
let r = binder().receipt_for_op(&c, &op, &id, &energy).unwrap();
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
assert_eq!(r.provenance, EnergyClass::Unaccounted);
assert!(r.meter.is_none(), "an estimator figure never carries a meter binding");
let v = validate(&c, &r).unwrap();
@ -381,13 +630,13 @@ mod tests {
// Receipt-time enforcement (JouleContract §5.3): the deviation is on
// the record as a negative residual; amend-or-fix is the resolution.
let c = contract(&["src/"]);
let (op, id) = op(&["src/lib.rs"]);
let signed = op(&["src/lib.rs"]);
let energy = Energy {
measured_uj: Some(50_000), // 0.05 J against a 0.01 J ceiling
estimated_uj: 50_000,
provenance: Provenance::HwShunt,
};
let r = binder().receipt_for_op(&c, &op, &id, &energy).unwrap();
let r = binder().receipt_for_op(&c, &signed, &prior(), &energy).unwrap();
assert!(r.residual() < 0.0);
assert!(validate(&c, &r).is_ok(), "the system keeps running; the record decides");
}