From 936d26761aa02d7a772a379d0f10dd5697696a99 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 21:26:27 -0400 Subject: [PATCH] =?UTF-8?q?jcp=20=C2=A74.7:=20an=20amendment=20may=20only?= =?UTF-8?q?=20tighten=20against=20what=20the=20grant=20has=20LEFT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `amend` and `verify_amendment` compared the amended budget against `grant.budget_uj` — the budget as ISSUED. On a 1 000 µJ grant whose run had already spent 900, an amendment of 1 000 passed as a "tightening" while handing the run ten times its remainder, contradicting the module's own guarantee that "steering can never escalate a running agent's authority". The hole was not that the check was missing; it was that the checkpoint had no way to say what had been spent, so there was nothing for the check to measure against. `Checkpoint` now carries `spent: Option` — a cumulative microjoule figure with its §5 provenance grade. Cumulative rather than per-interval, so one checkpoint answers "how much is left" without the verifier holding the whole chain and without a missing link silently understating the total. `remaining_uj` derives the remainder and returns `None` when the checkpoint is silent: unknown is not zero, and conflating them is how an amendment hands back a whole grant. The ceiling is now the remainder where the checkpoint states its spend, and the issued budget where it does not. The fallback is deliberately the old, weaker rule rather than a refusal — a checkpoint written before the field existed is still a valid checkpoint, and rejecting it would break running deployments to fix a hole it cannot express. Enforced on both sides: `amend` refusing is not enough, because an enforcement point handed a signed amendment never calls `amend`. What the figure is worth, stated rather than implied: the checkpoint is authored by the agent, so its declared spend is a signed claim, not an independent measurement. Two things make it hard to shrink — it is attributable, and `checkpoint_follows` catches a later checkpoint that declares less than an earlier one or drops a field it once carried, which would otherwise let an agent recover its full ceiling by going quiet. Wire change is additive: `spent: None` is skipped, so `Checkpoint::id()` and every sealed vector are byte-identical to before the field existed. Regenerating the pack proves it — `standing.json` gains one entry and no published id or signature moves. The new `checkpoint_with_spend` vector publishes the issued budget alongside the spend so a third party can redo the arithmetic, and its id necessarily differs from `checkpoint`'s, which is what makes the additivity claim non-vacuous. The remainder rule itself is a refusal, and a refusal has nothing to sign, so it is pinned by C21 and unit tests instead. SPEC §4.7 and C21 updated: the remainder is normative, both sides MUST enforce it, an absent spend MUST NOT be read as zero, and a declared spend MUST NOT decrease. Co-Authored-By: Claude Opus 5 --- jcp/SPEC.md | 44 ++- jcp/conformance/v1/standing.json | 7 + .../jcp-conformance/src/bin/gen_vectors.rs | 26 +- jcp/jcp-rs/crates/jcp-conformance/src/lib.rs | 39 ++- .../crates/jcp-conformance/tests/pack.rs | 24 +- jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs | 315 +++++++++++++++++- 6 files changed, 419 insertions(+), 36 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index b20e80943..86fc564ee 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -256,16 +256,38 @@ principal. Steering is an issuer power, like revocation (§4.3): an **amendment** is a soft revoke that re-bounds the run rather than killing it. A checkpoint cites the active grant and a content-addressed state commitment, -signed by the agent. An amendment cites a checkpoint, is signed by the grant's -issuer, MUST differ from the agent, and MUST only **tighten** — the -continuation's budget is at most the checkpoint grant's, so steering can never -escalate a running agent's authority; it MAY also hand the agent a new -instruction-set commitment. The run resumes under a **continuation grant** the -issuer re-issues to the same agent with the amended budget, linked back to the -steered grant. This continuation is an issuer re-binding, not a holder -delegation — an auditor verifies it alongside the signed checkpoint+amendment -pair, which together record who intervened, when, and how. Conformance class -**C21**. +signed by the agent. It MAY also state what the run has **spent** so far: a +cumulative microjoule figure with the provenance grade of §5. An amendment +cites a checkpoint, is signed by the grant's issuer, MUST differ from the +agent, and MUST only **tighten**; it MAY also hand the agent a new +instruction-set commitment. + +What "tighten" is measured against is normative, and it is the **remainder**, +not the issued budget: + +- Where the checkpoint states its spend, an amendment MUST NOT set a budget + above `grant.budget_uj − spent.uj`, saturating at zero. Both the amending + side and the verifying side MUST enforce this: an enforcement point handed a + signed amendment never calls the amending routine, so a check that lives only + there is not a check. +- Where the checkpoint states no spend, the ceiling is the issued + `grant.budget_uj`. This is the weaker rule and is retained so that a + checkpoint written before the field existed remains valid; implementations + MUST NOT read an absent spend as zero spend, and SHOULD report the remainder + as unknown rather than as the full budget. +- A declared spend is cumulative, so across one run it MUST NOT decrease, and a + checkpoint that once stated a spend MUST NOT later omit it. Otherwise an agent + recovers its full issued ceiling by going quiet. + +The figure is the agent's own signed claim, not an independent measurement; its +`provenance` grade travels with it so the amender can see which kind of claim it +is, and a deployment that needs more binds receipts (§5) to the checkpoint. + +The run resumes under a **continuation grant** the issuer re-issues to the same +agent with the amended budget, linked back to the steered grant. This +continuation is an issuer re-binding, not a holder delegation — an auditor +verifies it alongside the signed checkpoint+amendment pair, which together +record who intervened, when, and how. Conformance class **C21**. ### 4.8 Dual ceilings: a money budget beside the joule budget (OPTIONAL) @@ -740,7 +762,7 @@ enforced budget make the dangerous action unreachable rather than observed. Behavioral scoring MAY be layered above as a secondary signal; it is never the gate. - **C20 — standing grants:** an event-triggered firing mints a strictly-weaker child that traces to the standing grant; the wrong event class, an exhausted firing count, and a firing past the cumulative budget are each refused without advancing the ledger; a policy that would amplify its grant is refused at author time. -- **C21 — checkpoints + amendments:** an agent checkpoints its own state; only the grant's issuer — a principal distinct from the agent — may amend, only to tighten; an agent steering itself, a non-issuer amending, a widening amendment, and an amendment bound to a different checkpoint are each refused. +- **C21 — checkpoints + amendments:** an agent checkpoints its own state; only the grant's issuer — a principal distinct from the agent — may amend, only to tighten, and only up to what the grant has **left** at that checkpoint; an agent steering itself, a non-issuer amending, a widening amendment, an amendment above the remainder of a checkpoint that stated its spend, and an amendment bound to a different checkpoint are each refused. - **C23 — receipt binds its payload + profile migration:** a `jcp/2` receipt commits the `payload_hash` it decided over; substituting only the payload (same grant, action, and cost) fails verification — the forgery a payload-blind receipt could not detect; and receipts published under `jcp/1` still verify, because the verifier recomputes the preimage under the profile the receipt names rather than assuming one. - **C24 — caveats:** a caveat embedded in the grant is enforced offline by the decision point; `PayloadIs` makes a grant one-shot (a replay with different arguments is denied `caveat-unsatisfied`); `MaxCostUj` bounds a single invocation independently of the cumulative budget; a caveat may narrow below the capability; attenuation inherits every parent caveat and refuses to drop one; and a grant with no caveats encodes byte-identically to one minted before they existed. - **C22 — dual ceilings:** a money ceiling beside the joule ceiling is enforced independently — the first to bind denies (`funds-exceeded` / `budget-exceeded`), a foreign-currency cost is refused `currency-mismatch` rather than converted, an allow debits both dimensions and a deny debits neither, and an absent money ceiling encodes byte-identically to a joules-only grant (so its content id and signature are unchanged). Attenuation narrows money independently: dropping, raising, or re-denominating a bounded parent's ceiling is refused; a plain attenuation inherits it. diff --git a/jcp/conformance/v1/standing.json b/jcp/conformance/v1/standing.json index cffe5e52d..df367b1a6 100644 --- a/jcp/conformance/v1/standing.json +++ b/jcp/conformance/v1/standing.json @@ -8,6 +8,13 @@ "id_hex": "ab4554898620355d49b9caa9105529533da047c944d9f8b5ab66ea8729aaf3f6", "signature_hex": "13a4432a4f615de526b981cb45220936a905ee89a824289396752b9e17a4a90ac7aa3c8bb82bf2f24c93bfe9a74bf1c2c0483174ef1d7f1b2dde7a1a416c4f09" }, + "checkpoint_with_spend": { + "grant_budget_uj": 1000, + "id_hex": "f15f4226a853f2bb6e026afdf96f45599a29aa8c8e49e0904fc4c6fe115eede0", + "remaining_uj": 100, + "signature_hex": "a580b2eb89849c5740a4c4772bae6b662a3ade271e22320254647340a9eb45a27073352a7cee1079660a7ef5e93c303b15888a1964f62e3133e007291ca8940a", + "spent_uj": 900 + }, "continuation_grant": { "budget_uj": 400, "id_hex": "a901bad46c325834a3f90442cb74bbc0f5e0bd1ba1510de3e7abb749da610ac4", diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs index a7d1c4808..92e329f76 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/bin/gen_vectors.rs @@ -6,9 +6,9 @@ use ed25519_dalek::SigningKey; use jcp_grant::issue; use jcp_grant::standing::{issue_policy, mint_on_trigger, MintLedger, TriggerEvent}; -use jcp_grant::checkpoint::{amend, apply_amendment, checkpoint}; +use jcp_grant::checkpoint::{amend, apply_amendment, checkpoint, remaining_uj, Spend}; use jcp_runtime::{Price, Runtime}; -use jcp_schema::{Capability, ContentId, Did, Energy, Frame, FrameKind, Funds, Grant, Trust}; +use jcp_schema::{Capability, ContentId, Did, Energy, Frame, FrameKind, Funds, Grant, Provenance, Trust}; fn main() { let out = std::env::args().nth(1).unwrap_or_else(|| "conformance/v1".to_string()); @@ -104,15 +104,35 @@ fn main() { capability: Capability::new(resource, ["read", "write"]), budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [6; 16], budget_funds: None, caveats: Vec::new(), }).expect("run grant"); - let cp = checkpoint(&agent, &run_grant, ContentId::of_bytes(b"state-1"), 0, [7; 16]).expect("cp"); + let cp = checkpoint(&agent, &run_grant, ContentId::of_bytes(b"state-1"), 0, [7; 16], None).expect("cp"); let am = amend(&issuer, &run_grant, &cp, 400, Some(ContentId::of_bytes(b"instr")), [8; 16]).expect("amend"); let cont = apply_amendment(&issuer, &run_grant, &cp, &am).expect("continuation"); + // A second checkpoint that states what the run has already spent. Its + // `spent` field is omitted from the signed core when absent, so `checkpoint` + // above keeps the id and signature it had before the field existed — the + // two entries together pin that the extension is additive. This one pins + // the encoding of the field itself, including how `Provenance` is spelled + // inside the core. The remainder rule it enables is a refusal, and a + // refusal has nothing to sign, so it is pinned by C-checks, not a vector. + let spent_cp = checkpoint( + &agent, &run_grant, ContentId::of_bytes(b"state-2"), 1, [9; 16], + Some(Spend { uj: 900, provenance: Provenance::HwShunt }), + ).expect("spent cp"); + let standing_vectors = serde_json::json!({ "standing_grant": {"id_hex": standing.grant.id().to_hex(), "signature_hex": hex::encode(&standing.signature)}, "mint_policy": {"id_hex": ContentId::of(&policy.policy).unwrap().to_hex(), "signature_hex": hex::encode(&policy.signature)}, "minted_child": {"id_hex": minted.grant.id().to_hex(), "parent_hex": minted.grant.parent.as_ref().map(|p| p.to_hex()), "budget_uj": minted.grant.budget_uj}, "checkpoint": {"id_hex": cp.checkpoint.id().to_hex(), "signature_hex": hex::encode(&cp.signature)}, + "checkpoint_with_spend": { + "id_hex": spent_cp.checkpoint.id().to_hex(), + "signature_hex": hex::encode(&spent_cp.signature), + // The grant's issued budget travels with the entry so a third + // party can recompute the remainder instead of taking it on faith. + "grant_budget_uj": run_grant.grant.budget_uj, + "spent_uj": 900, "remaining_uj": remaining_uj(&run_grant.grant, &spent_cp.checkpoint), + }, "amendment": {"id_hex": am.amendment.id().to_hex(), "signature_hex": hex::encode(&am.signature), "new_budget_uj": am.amendment.new_budget_uj}, "continuation_grant": {"id_hex": cont.grant.id().to_hex(), "parent_hex": cont.grant.parent.as_ref().map(|p| p.to_hex()), "budget_uj": cont.grant.budget_uj}, }); diff --git a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs index be73a049c..bbdf9201a 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/src/lib.rs @@ -31,7 +31,7 @@ use jcp_grant::{attenuate, attenuate_priced, caveats_narrow, issue, revoke, veri use jcp_runtime::{Price, Runtime}; use jcp_schema::{ canonical_cbor, from_canonical_cbor, Capability, ContentId, DenyReason, Did, Energy, Frame, - Caveat, FrameKind, Funds, Grant, SignedGrant, Trust, Verdict, + Caveat, FrameKind, Funds, Grant, Provenance, SignedGrant, Trust, Verdict, }; /// A single conformance check. @@ -653,7 +653,7 @@ fn c20_standing() -> Result<(), String> { fn c21_checkpoint() -> Result<(), String> { - use jcp_grant::checkpoint::{amend, apply_amendment, checkpoint, verify_amendment, verify_checkpoint, CheckpointError}; + use jcp_grant::checkpoint::{amend, apply_amendment, checkpoint, remaining_uj, verify_amendment, verify_checkpoint, CheckpointError, Spend}; // steward issues to a distinct agent. let steward = sk(1); let agent = sk(2); @@ -661,7 +661,7 @@ fn c21_checkpoint() -> Result<(), String> { let g = grant(&steward, did(&agent), r, &["read", "write"], 1_000, 0); // The agent checkpoints its own state; the steward (distinct issuer) steers. - let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1; 16]).map_err(|e| format!("{e:?}"))?; + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1; 16], None).map_err(|e| format!("{e:?}"))?; verify_checkpoint(&cp, &g).map_err(|e| format!("{e:?}"))?; let am = amend(&steward, &g, &cp, 400, Some(ContentId::of_bytes(b"instr")), [2; 16]).map_err(|e| format!("{e:?}"))?; verify_amendment(&am, &g, &cp).map_err(|e| format!("{e:?}"))?; @@ -674,9 +674,40 @@ fn c21_checkpoint() -> Result<(), String> { return Err("continuation budget was not tightened".into()); } + // An amendment may not restore budget the run has already spent. The old + // rule compared the amendment against the grant's ISSUED budget, so on a + // 1 000 µJ grant with 900 µJ already spent, an amendment of 1 000 passed + // as "tightening" while handing the run ten times its remainder. + let spent_cp = checkpoint( + &agent, + &g, + ContentId::of_bytes(b"state-2"), + 1, + [3; 16], + Some(Spend { uj: 900, provenance: Provenance::HwShunt }), + ) + .map_err(|e| format!("{e:?}"))?; + verify_checkpoint(&spent_cp, &g).map_err(|e| format!("{e:?}"))?; + if remaining_uj(&g.grant, &spent_cp.checkpoint) != Some(100) { + return Err("remaining budget was not derived from the checkpoint".into()); + } + if !matches!( + amend(&steward, &g, &spent_cp, 1_000, None, [4; 16]), + Err(CheckpointError::Amplifies(_)) + ) { + return Err("an amendment restored budget the run had already spent".into()); + } + // At or under the remainder it is still allowed. + amend(&steward, &g, &spent_cp, 100, None, [5; 16]).map_err(|e| format!("{e:?}"))?; + // A checkpoint that states no spend keeps the older, weaker ceiling, and + // says so by having no remainder to report. + if remaining_uj(&g.grant, &cp.checkpoint).is_some() { + return Err("a checkpoint with no spend reported a remainder".into()); + } + // Negative: the agent cannot steer itself, even on a self-issued grant. let self_g = grant(&agent, did(&agent), ContentId::of_bytes(b"t"), &["read"], 100, 0); - let self_cp = checkpoint(&agent, &self_g, ContentId::of_bytes(b"s"), 0, [1; 16]).map_err(|e| format!("{e:?}"))?; + let self_cp = checkpoint(&agent, &self_g, ContentId::of_bytes(b"s"), 0, [1; 16], None).map_err(|e| format!("{e:?}"))?; if !matches!(amend(&agent, &self_g, &self_cp, 50, None, [2; 16]), Err(CheckpointError::SeparationOfDuties)) { return Err("an agent steered itself".into()); } diff --git a/jcp/jcp-rs/crates/jcp-conformance/tests/pack.rs b/jcp/jcp-rs/crates/jcp-conformance/tests/pack.rs index 0fcf9deb3..e6f8949f1 100644 --- a/jcp/jcp-rs/crates/jcp-conformance/tests/pack.rs +++ b/jcp/jcp-rs/crates/jcp-conformance/tests/pack.rs @@ -25,7 +25,9 @@ //! - **`dual_ceilings.json`** — §4.8's money-beside-joules ceiling, including the //! two refusals that make it real (`funds-exceeded`, `currency-mismatch`). Those //! are negative vectors, and nothing executed them. -//! - **`standing.json`** — the standing-grant lineage. +//! - **`standing.json`** — the standing-grant lineage, and (added with §4.7's +//! remainder rule) the spend-bearing checkpoint whose arithmetic a third +//! party can redo. //! //! Note `legacy_jcp1.json` is deliberately absent from the drift check below: it is //! a frozen historical artifact ("not minted any more"), so regenerating it would @@ -221,6 +223,26 @@ fn the_standing_lineage_links_as_far_as_the_pack_allows() { "a minted child may not exceed the amended ceiling" ); + // The spend-bearing checkpoint (§4.7's remainder rule). Two things are + // checkable from the published files alone: the remainder is arithmetic a + // third party can redo, and the entry's id differs from `checkpoint`'s — + // which is what makes the additivity claim non-vacuous. `checkpoint` was + // generated with no spend and keeps the id it had before the field existed; + // this one carries the field and therefore cannot. + let sp = &d["checkpoint_with_spend"]; + let issued = sp["grant_budget_uj"].as_u64().expect("the entry publishes the issued budget"); + let spent = sp["spent_uj"].as_u64().expect("the entry publishes the spend"); + let remaining = sp["remaining_uj"].as_u64().expect("the entry publishes the remainder"); + assert_eq!( + issued.saturating_sub(spent), remaining, + "the published remainder is not the issued budget minus the spend" + ); + assert!(remaining < issued, "a vector for the remainder rule must actually have spent something"); + assert_ne!( + id("checkpoint_with_spend"), id("checkpoint"), + "a declared spend must change the checkpoint's id — it is signed, not advisory" + ); + let published: Vec = ["standing_grant", "mint_policy", "minted_child", "amendment", "checkpoint", "continuation_grant"] .iter() .map(|k| id(k)) diff --git a/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs b/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs index 732341688..e1a930d39 100644 --- a/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs +++ b/jcp/jcp-rs/crates/jcp-grant/src/checkpoint.rs @@ -19,12 +19,29 @@ //! (§4.3); an amendment is a *soft revoke* that re-bounds the run instead of //! killing it. //! -//! An amendment may only **tighten**: the continuation's budget is at most the -//! checkpoint grant's, so steering can never escalate a running agent's -//! authority. Widening is just issuing a new grant, and is out of scope here. +//! An amendment may only **tighten**, and the bar is what the grant has *left* +//! at the checkpoint, not what it was issued with. The distinction is the whole +//! point: on a 1 000 µJ grant whose run has already spent 900, an amendment of +//! 1 000 reads as "tightening" against the issued figure while handing the run +//! ten times its remainder. A checkpoint that states its [`Spend`] is therefore +//! measured against [`remaining_uj`]; one that states nothing falls back to the +//! issued budget, which is the older and weaker rule (see +//! [`amendable_ceiling_uj`] for why the fallback is not a refusal). Widening is +//! just issuing a new grant, and is out of scope here. +//! +//! What the spend figure is worth. The checkpoint is authored by the *agent* — +//! the party the remainder constrains — so its declared spend is a claim, not +//! an independent measurement, and the `provenance` grade travels with it so +//! the amender can see which kind of claim it is. Two things make the claim +//! hard to shrink: it is signed, so understating it is attributable, and it is +//! cumulative, so [`checkpoint_follows`] catches a later checkpoint that +//! declares less than an earlier one or drops the field it once carried. What +//! this layer does not do is independently meter the run; a deployment that +//! needs that binds receipts (§5) to the checkpoint instead of trusting the +//! figure. use ed25519_dalek::{Signature, Signer, SigningKey, Verifier}; -use jcp_schema::{canonical_cbor, ContentId, Did, Grant, SignedGrant}; +use jcp_schema::{canonical_cbor, ContentId, Did, Grant, Provenance, SignedGrant}; use serde::{Deserialize, Serialize}; use crate::{issue, GrantError}; @@ -42,6 +59,45 @@ pub struct Checkpoint { /// Monotonic checkpoint index within the run. pub seq: u64, pub nonce: [u8; 16], + /// What the run has spent under this grant, as of this boundary. + /// + /// `None` is the historical shape and is skipped on the wire, so every + /// checkpoint written before this field existed keeps its + /// [`Checkpoint::id`] and the sealed conformance vectors are unchanged. + /// It is also the *weaker* shape — see [`remaining_uj`]. + /// + /// `skip_serializing_if` is load-bearing, not tidiness: the JCR-1 signed + /// core forbids nulls, so without it `Checkpoint::id()` fails outright with + /// `DisallowedType("null")` on every checkpoint that states no spend. + /// Omission is the only encoding the canonical form permits. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spent: Option, +} + +/// Energy spent under a grant up to a checkpoint, with the provenance of the +/// figure. +/// +/// Cumulative rather than since-the-previous-checkpoint: one checkpoint then +/// answers "how much is left" on its own, without the verifier having to hold +/// the whole chain and without a missing link silently understating the total. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Spend { + /// Microjoules spent under this grant so far. + pub uj: u64, + /// How the figure was obtained. Carried because a budget decision made on + /// an `Estimator` figure is a different decision from one made on a + /// hardware reading, and the amendment it justifies is signed either way. + pub provenance: Provenance, +} + +/// What the grant has left at this checkpoint, when the checkpoint says what +/// it spent. +/// +/// `None` when the checkpoint declares no spend. That is not zero — it is +/// *unknown*, and the two must not be confused: treating unknown as zero +/// would let an amendment hand back the whole grant. +pub fn remaining_uj(grant: &Grant, cp: &Checkpoint) -> Option { + cp.spent.map(|s| grant.budget_uj.saturating_sub(s.uj)) } impl Checkpoint { @@ -114,20 +170,83 @@ impl From for CheckpointError { } } +/// The most an amendment at this checkpoint may grant. +/// +/// The remainder where the checkpoint declares its spend; the whole grant +/// otherwise. The fallback is deliberately the old, weaker rule rather than a +/// refusal: a checkpoint written before `spent` existed is still a valid +/// checkpoint, and rejecting it would break running deployments to fix a hole +/// it cannot express. What it does mean is that the guarantee is only as +/// strong as the checkpoint — which [`remaining_uj`] returning `None` is how a +/// verifier finds out. +fn amendable_ceiling_uj(grant: &Grant, cp: &Checkpoint) -> u64 { + remaining_uj(grant, cp).unwrap_or(grant.budget_uj) +} + +/// Check that `next` is a lawful successor to `prev` within one run. +/// +/// Each object on its own is well-formed; this is the relationship between +/// them, and it is where an understated spend becomes visible. A declared +/// spend is cumulative, so across a chain it may only rise, and a checkpoint +/// that once declared a figure may not later go silent — otherwise an agent +/// could check in at 900 µJ spent, then check in again saying nothing, and +/// recover the whole issued budget as its ceiling. +/// +/// Callers that keep the chain SHOULD run this before honouring an amendment +/// at `next`. It is deliberately not folded into [`verify_checkpoint`], which +/// is given one checkpoint and cannot see a predecessor it was never handed. +pub fn checkpoint_follows(prev: &Checkpoint, next: &Checkpoint) -> Result<(), CheckpointError> { + if next.grant_id != prev.grant_id { + return Err(CheckpointError::ChainBroken("checkpoints name different grants")); + } + if next.author != prev.author { + return Err(CheckpointError::ChainBroken("checkpoints have different authors")); + } + if next.seq <= prev.seq { + return Err(CheckpointError::ChainBroken("checkpoint seq did not advance")); + } + match (prev.spent, next.spent) { + (Some(p), Some(n)) if n.uj < p.uj => Err(CheckpointError::Amplifies( + "cumulative spend fell between checkpoints", + )), + (Some(_), None) => Err(CheckpointError::Amplifies( + "a checkpoint that declared its spend later declared none", + )), + _ => Ok(()), + } +} + +/// Say which ceiling was exceeded, since the two are different claims. +fn amplify_reason(cp: &Checkpoint) -> &'static str { + if cp.spent.is_some() { + "amended budget exceeds what the grant has left at this checkpoint" + } else { + "amended budget exceeds the grant" + } +} + /// Emit a checkpoint. The signer MUST be the grant's `subject` — only the /// running agent commits to its own state. +/// `spent` is `Option` rather than a plain figure because a run that genuinely +/// cannot meter itself must be able to say so, and because the difference +/// matters: `None` means *unknown*, not zero, and an amendment at an +/// unknown-spend checkpoint falls back to the weaker whole-grant ceiling. +/// It is a parameter rather than a second constructor so that every call site +/// decides visibly. +#[allow(clippy::too_many_arguments)] pub fn checkpoint( agent_sk: &SigningKey, grant: &SignedGrant, state_hash: ContentId, seq: u64, nonce: [u8; 16], + spent: Option, ) -> Result { let author = Did::from_verifying_key(&agent_sk.verifying_key()); if author != grant.grant.subject { return Err(CheckpointError::NotTheSubject); } - let cp = Checkpoint { grant_id: grant.grant.id(), author, state_hash, seq, nonce }; + let cp = Checkpoint { grant_id: grant.grant.id(), author, state_hash, seq, nonce, spent }; let bytes = canonical_cbor(&cp).map_err(GrantError::from)?; let signature = agent_sk.sign(&bytes).to_bytes().to_vec(); Ok(SignedCheckpoint { checkpoint: cp, signature }) @@ -166,8 +285,16 @@ pub fn amend( if amender == cp.checkpoint.author { return Err(CheckpointError::SeparationOfDuties); } - if new_budget_uj > grant.grant.budget_uj { - return Err(CheckpointError::Amplifies("amended budget exceeds the grant")); + // The ceiling is what the grant has LEFT, not what it was issued with. + // + // Comparing against `grant.budget_uj` was the bug: on a 1 000 000 µJ grant + // whose run had already spent 900 000, an amendment of 1 000 000 passed as + // "tightening" while handing the run ten times its remainder. An amendment + // is a soft revoke, and a soft revoke that restores spent budget is an + // escalation — which is exactly what this module's own documentation says + // steering can never do. + if new_budget_uj > amendable_ceiling_uj(&grant.grant, &cp.checkpoint) { + return Err(CheckpointError::Amplifies(amplify_reason(&cp.checkpoint))); } let amendment = Amendment { checkpoint_id: cp.checkpoint.id(), @@ -198,8 +325,8 @@ pub fn verify_amendment( if sa.amendment.amender == cp.checkpoint.author { return Err(CheckpointError::SeparationOfDuties); } - if sa.amendment.new_budget_uj > grant.grant.budget_uj { - return Err(CheckpointError::Amplifies("amended budget exceeds the grant")); + if sa.amendment.new_budget_uj > amendable_ceiling_uj(&grant.grant, &cp.checkpoint) { + return Err(CheckpointError::Amplifies(amplify_reason(&cp.checkpoint))); } let vk = sa.amendment.amender.verifying_key().map_err(GrantError::from)?; let bytes = canonical_cbor(&sa.amendment).map_err(GrantError::from)?; @@ -272,7 +399,7 @@ mod tests { #[test] fn checkpoint_round_trips_and_binds_to_subject() { let (_steward, agent, g) = setup(); - let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1u8; 16]).unwrap(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1u8; 16], None).unwrap(); verify_checkpoint(&cp, &g).unwrap(); assert_eq!(cp.checkpoint.author, g.grant.subject); } @@ -281,14 +408,14 @@ mod tests { fn agent_may_not_checkpoint_a_grant_it_does_not_hold() { let (steward, _agent, g) = setup(); // The steward is not the subject — its checkpoint is refused. - let r = checkpoint(&steward, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16]); + let r = checkpoint(&steward, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], None); assert!(matches!(r, Err(CheckpointError::NotTheSubject))); } #[test] fn issuer_amends_and_run_continues_under_tighter_budget() { let (steward, agent, g) = setup(); - let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1u8; 16]).unwrap(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1u8; 16], None).unwrap(); let am = amend(&steward, &g, &cp, 400, Some(ContentId::of_bytes(b"new-instructions")), [2u8; 16]).unwrap(); verify_amendment(&am, &g, &cp).unwrap(); let cont = apply_amendment(&steward, &g, &cp, &am).unwrap(); @@ -309,7 +436,7 @@ mod tests { budget_uj: 1_000, expiry: 0, parent: None, delegate_depth: None, nonce: [0u8; 16], budget_funds: None, caveats: Vec::new(), }; let g = issue(&agent, self_grant).unwrap(); - let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16]).unwrap(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], None).unwrap(); let r = amend(&agent, &g, &cp, 100, None, [2u8; 16]); assert!(matches!(r, Err(CheckpointError::SeparationOfDuties))); } @@ -318,7 +445,7 @@ mod tests { fn a_non_issuer_cannot_amend() { let (_steward, agent, g) = setup(); let stranger = SigningKey::from_bytes(&[9u8; 32]); - let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16]).unwrap(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], None).unwrap(); let r = amend(&stranger, &g, &cp, 100, None, [2u8; 16]); assert!(matches!(r, Err(CheckpointError::NotTheIssuer))); } @@ -326,7 +453,7 @@ mod tests { #[test] fn an_amendment_may_only_tighten() { let (steward, agent, g) = setup(); - let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16]).unwrap(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], None).unwrap(); let r = amend(&steward, &g, &cp, 5_000, None, [2u8; 16]); assert!(matches!(r, Err(CheckpointError::Amplifies(_)))); } @@ -334,10 +461,164 @@ mod tests { #[test] fn a_forged_amendment_for_another_checkpoint_is_refused() { let (steward, agent, g) = setup(); - let cp1 = checkpoint(&agent, &g, ContentId::of_bytes(b"s1"), 0, [1u8; 16]).unwrap(); - let cp2 = checkpoint(&agent, &g, ContentId::of_bytes(b"s2"), 1, [2u8; 16]).unwrap(); + let cp1 = checkpoint(&agent, &g, ContentId::of_bytes(b"s1"), 0, [1u8; 16], None).unwrap(); + let cp2 = checkpoint(&agent, &g, ContentId::of_bytes(b"s2"), 1, [2u8; 16], None).unwrap(); let am = amend(&steward, &g, &cp1, 400, None, [3u8; 16]).unwrap(); // The amendment was authored for cp1; presented against cp2 it fails. assert!(matches!(verify_amendment(&am, &g, &cp2), Err(CheckpointError::ChainBroken(_)))); } + // ---- the remainder rule ------------------------------------------------- + + #[test] + fn an_amendment_may_not_restore_spent_budget() { + // The hole this field exists to close. Before `spent`, both `amend` and + // `verify_amendment` compared against `grant.budget_uj` — the budget as + // ISSUED — so this amendment was accepted as a tightening while handing + // the run ten times what it had left. + let (steward, agent, g) = setup(); + let cp = checkpoint( + &agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], + Some(Spend { uj: 900, provenance: Provenance::HwShunt }), + ) + .unwrap(); + assert_eq!(remaining_uj(&g.grant, &cp.checkpoint), Some(100)); + assert!(matches!( + amend(&steward, &g, &cp, 1_000, None, [2u8; 16]), + Err(CheckpointError::Amplifies(_)) + )); + // One microjoule over the remainder is still over it. + assert!(matches!( + amend(&steward, &g, &cp, 101, None, [2u8; 16]), + Err(CheckpointError::Amplifies(_)) + )); + // At the remainder exactly, it is allowed. + let am = amend(&steward, &g, &cp, 100, None, [3u8; 16]).unwrap(); + verify_amendment(&am, &g, &cp).unwrap(); + let cont = apply_amendment(&steward, &g, &cp, &am).unwrap(); + assert_eq!(cont.grant.budget_uj, 100); + } + + #[test] + fn verify_amendment_enforces_the_remainder_too() { + // `amend` refusing is not enough: a steward could sign an over-budget + // amendment with its own key and hand it straight to an enforcement + // point, which never calls `amend`. The check has to live on the + // verifying side as well. + let (steward, agent, g) = setup(); + let cp = checkpoint( + &agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], + Some(Spend { uj: 900, provenance: Provenance::Estimator }), + ) + .unwrap(); + let forged = Amendment { + checkpoint_id: cp.checkpoint.id(), + amender: did(&steward), + new_budget_uj: 1_000, + instruction_id: None, + nonce: [7u8; 16], + }; + let sig = steward.sign(&canonical_cbor(&forged).unwrap()).to_bytes().to_vec(); + let signed = SignedAmendment { amendment: forged, signature: sig }; + assert!(matches!( + verify_amendment(&signed, &g, &cp), + Err(CheckpointError::Amplifies(_)) + )); + // And the continuation cannot be minted from it either. + assert!(matches!( + apply_amendment(&steward, &g, &cp, &signed), + Err(CheckpointError::Amplifies(_)) + )); + } + + #[test] + fn a_silent_checkpoint_keeps_the_older_weaker_ceiling() { + // Unknown is not zero, but it is also not a refusal: a checkpoint from + // before the field existed is still valid, and says so by having no + // remainder to report. + let (steward, agent, g) = setup(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], None).unwrap(); + assert_eq!(remaining_uj(&g.grant, &cp.checkpoint), None); + amend(&steward, &g, &cp, 1_000, None, [2u8; 16]).unwrap(); + assert!(matches!( + amend(&steward, &g, &cp, 1_001, None, [3u8; 16]), + Err(CheckpointError::Amplifies(_)) + )); + } + + #[test] + fn a_spend_beyond_the_budget_leaves_nothing_rather_than_wrapping() { + // An overspend is a real state — enforcement is per-debit and a final + // debit can land over the line. The remainder saturates at zero; it + // must not underflow into a vast ceiling. + let (steward, agent, g) = setup(); + let cp = checkpoint( + &agent, &g, ContentId::of_bytes(b"s"), 0, [1u8; 16], + Some(Spend { uj: 4_000, provenance: Provenance::HwShunt }), + ) + .unwrap(); + assert_eq!(remaining_uj(&g.grant, &cp.checkpoint), Some(0)); + assert!(matches!( + amend(&steward, &g, &cp, 1, None, [2u8; 16]), + Err(CheckpointError::Amplifies(_)) + )); + // Zero is the only lawful continuation: the run may be wound down + // under a grant that can no longer buy anything, which is different + // from killing it outright (§4.3). + amend(&steward, &g, &cp, 0, None, [3u8; 16]).unwrap(); + } + + #[test] + fn the_spend_field_does_not_change_an_existing_checkpoint() { + // The wire extension is additive: `spent: None` is skipped, so the + // signed core is byte-identical to the shape that predates the field + // and every sealed vector still verifies. + let (_steward, agent, g) = setup(); + let cp = checkpoint(&agent, &g, ContentId::of_bytes(b"state-1"), 0, [1u8; 16], None).unwrap(); + let bytes = canonical_cbor(&cp.checkpoint).unwrap(); + assert!( + !bytes.windows(5).any(|w| w == b"spent"), + "an absent spend must not appear in the signed bytes" + ); + // And a stated spend does change the id — it is signed, not advisory. + let with = checkpoint( + &agent, &g, ContentId::of_bytes(b"state-1"), 0, [1u8; 16], + Some(Spend { uj: 0, provenance: Provenance::Estimator }), + ) + .unwrap(); + assert_ne!( + with.checkpoint.id(), cp.checkpoint.id(), + "a declared spend of zero is a claim, and differs from no claim" + ); + } + + #[test] + fn cumulative_spend_may_not_fall_or_go_silent_across_a_run() { + let (_steward, agent, g) = setup(); + let mk = |seq: u64, n: u8, spend: Option| { + checkpoint(&agent, &g, ContentId::of_bytes(b"s"), seq, [n; 16], spend) + .unwrap() + .checkpoint + }; + let first = mk(0, 1, Some(Spend { uj: 900, provenance: Provenance::HwShunt })); + let rose = mk(1, 2, Some(Spend { uj: 950, provenance: Provenance::HwShunt })); + checkpoint_follows(&first, &rose).unwrap(); + let fell = mk(1, 3, Some(Spend { uj: 100, provenance: Provenance::HwShunt })); + assert!(matches!( + checkpoint_follows(&first, &fell), + Err(CheckpointError::Amplifies(_)) + )); + let silent = mk(1, 4, None); + assert!(matches!( + checkpoint_follows(&first, &silent), + Err(CheckpointError::Amplifies(_)) + )); + // Going from silent to stated is fine — that is a deployment turning + // metering on mid-run, not an agent walking a figure back. + checkpoint_follows(&mk(0, 5, None), &rose).unwrap(); + // And the chain checks are still chain checks. + assert!(matches!( + checkpoint_follows(&rose, &first), + Err(CheckpointError::ChainBroken(_)) + )); + } } -- 2.54.0 (Apple Git-157)