jcp-cite: retrieval provenance — a checkable record of what an agent READ #32
5 changed files with 512 additions and 0 deletions
13
jcp/jcp-rs/Cargo.lock
generated
13
jcp/jcp-rs/Cargo.lock
generated
|
|
@ -535,6 +535,19 @@ dependencies = [
|
|||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jcp-cite"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"jcp-grant",
|
||||
"jcp-receipt",
|
||||
"jcp-runtime",
|
||||
"jcp-schema",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jcp-cli"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ members = [
|
|||
"crates/jcp-trace",
|
||||
"crates/jcp-a2a",
|
||||
"crates/jcp-x402",
|
||||
"crates/jcp-cite",
|
||||
"crates/jcp-policy",
|
||||
"crates/jcp-prompt",
|
||||
"crates/jcp-orchestrate",
|
||||
|
|
|
|||
20
jcp/jcp-rs/crates/jcp-cite/Cargo.toml
Normal file
20
jcp/jcp-rs/crates/jcp-cite/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "jcp-cite"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Retrieval provenance for JCP — a signed, content-addressed record of what an agent READ to produce an answer, not merely what it did. Each source is bound by content id (so a deleted or edited source makes the citation verifiably dangling rather than silently broken) and by the grant it was read under (so a reader learns which sources they are not cleared to open). The citation's content id is the retrieval frame's payload_hash, so the jcp/2 receipt commits it: the receipt proves the retrieval was authorized and metered, the citation records what it consulted."
|
||||
|
||||
[dependencies]
|
||||
jcp-schema = { workspace = true }
|
||||
jcp-grant = { workspace = true }
|
||||
jcp-runtime = { workspace = true }
|
||||
jcp-receipt = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
95
jcp/jcp-rs/crates/jcp-cite/examples/cite.rs
Normal file
95
jcp/jcp-rs/crates/jcp-cite/examples/cite.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! An agent answers a question, records what it READ, and seals a receipt that
|
||||
//! commits the provenance. Then a reviewer checks the three things a rendered
|
||||
//! citation link cannot tell them: is the answer unedited, are the sources
|
||||
//! intact, and which sources may this reviewer not open.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p jcp-cite --example cite
|
||||
//! ```
|
||||
|
||||
use ed25519_dalek::SigningKey;
|
||||
use jcp_cite::{verify, Citation, Source};
|
||||
use jcp_grant::issue;
|
||||
use jcp_receipt::{extract, seal};
|
||||
use jcp_runtime::Runtime;
|
||||
use jcp_schema::{Capability, ContentId, Did, Energy, Grant};
|
||||
|
||||
fn main() {
|
||||
let issuer = SigningKey::from_bytes(&[1u8; 32]);
|
||||
let agent = SigningKey::from_bytes(&[2u8; 32]);
|
||||
let agent_did = Did::from_verifying_key(&agent.verifying_key());
|
||||
let corpus = ContentId::of_bytes(b"acme-workspace");
|
||||
|
||||
// The agent holds two grants — one per sensitivity domain.
|
||||
let g_finance = ContentId::of_bytes(b"grant:finance");
|
||||
let g_people = ContentId::of_bytes(b"grant:people");
|
||||
|
||||
// It reads two documents to answer, and records EACH with the content it saw
|
||||
// and the grant it read under.
|
||||
let doc_a = b"Q3 revenue was 4.2M, up 18% YoY.";
|
||||
let doc_b = b"Headcount plan: 12 hires in Q4.";
|
||||
let answer = b"Revenue grew 18%, and 12 hires are planned for Q4.";
|
||||
|
||||
let citation = Citation::new(
|
||||
agent_did,
|
||||
ContentId::of_bytes(answer),
|
||||
vec![
|
||||
Source::new(ContentId::of_bytes(doc_a), g_finance).at("/finance/q3-report"),
|
||||
Source::new(ContentId::of_bytes(doc_b), g_people).at("/people/q4-plan"),
|
||||
],
|
||||
4_200, // µJ the retrieval consumed
|
||||
[7; 16],
|
||||
);
|
||||
println!("agent read {} sources → answer, {} µJ", citation.sources.len(), citation.energy_uj);
|
||||
|
||||
// The retrieval is decided and metered under a real grant, and the receipt
|
||||
// commits the citation by content id.
|
||||
let grant = Grant {
|
||||
issuer: Did::from_verifying_key(&issuer.verifying_key()),
|
||||
subject: agent_did,
|
||||
capability: Capability::new(corpus, ["retrieve"]),
|
||||
budget_uj: 1_000_000,
|
||||
budget_funds: None,
|
||||
caveats: Vec::new(),
|
||||
expiry: 0,
|
||||
parent: None,
|
||||
delegate_depth: None,
|
||||
nonce: [0; 16],
|
||||
};
|
||||
let sg = issue(&issuer, grant.clone()).unwrap();
|
||||
let frame = citation.frame(grant.id(), corpus).unwrap();
|
||||
let decision = Runtime::new().decide(&frame, &sg, agent_did, Energy::measured(citation.energy_uj), 1);
|
||||
let steward = SigningKey::from_bytes(&[9u8; 32]);
|
||||
let receipt = seal(&frame, &grant, &decision, &steward, "did:example:steward").unwrap();
|
||||
let core = extract(&receipt, "did:example:steward", &steward.verifying_key().to_bytes()).unwrap();
|
||||
|
||||
let sealed = citation.clone().seal(&agent).unwrap();
|
||||
println!(
|
||||
"receipt commits the citation: {} ({} µJ on the receipt)\n",
|
||||
core.payload_hash.as_deref() == Some(citation.id().unwrap().to_hex().as_str()),
|
||||
core.cost_uj
|
||||
);
|
||||
|
||||
// ---- a reviewer, holding only the receipt + the signed citation ----
|
||||
println!("── reviewer checks ──");
|
||||
println!(" citation signature valid: {}", verify(&sealed).is_ok());
|
||||
println!(" answer unedited: {}", sealed.citation.verify_answer(answer).is_ok());
|
||||
println!(" source 0 intact: {}", sealed.citation.verify_source(0, doc_a).is_ok());
|
||||
|
||||
// Now the finance doc is edited after the fact. A URL would still resolve —
|
||||
// to the NEW text. The content-bound citation catches it.
|
||||
let edited = b"Q3 revenue was 9.9M.";
|
||||
println!(
|
||||
" source 0 after an edit: {} ← dangling detected, not silently wrong",
|
||||
sealed.citation.verify_source(0, edited).is_ok()
|
||||
);
|
||||
|
||||
// A reviewer cleared only for finance is TOLD which sources they may not open.
|
||||
let unreadable = sealed.citation.unreadable_by(&[g_finance]);
|
||||
println!(" sources this reviewer may not open: {:?} (of {})", unreadable, sealed.citation.sources.len());
|
||||
for i in &unreadable {
|
||||
if let Some(loc) = sealed.citation.sources[*i].locator.as_deref() {
|
||||
println!(" → {loc} requires authority you do not hold");
|
||||
}
|
||||
}
|
||||
}
|
||||
383
jcp/jcp-rs/crates/jcp-cite/src/lib.rs
Normal file
383
jcp/jcp-rs/crates/jcp-cite/src/lib.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
//! **Retrieval provenance** — a signed record of what an agent *read* to
|
||||
//! produce an answer, not merely what it did.
|
||||
//!
|
||||
//! Agent platforms log actions. A workspace agent's audit trail will faithfully
|
||||
//! record that it posted a report, filed a bug, updated a row — and record
|
||||
//! nothing about the pages, rows and documents it consulted to decide any of it.
|
||||
//! The answer therefore cannot be reconstructed, and a reviewer cannot tell
|
||||
//! whether it was grounded in the corpus or invented. Citations, where they
|
||||
//! exist at all, are convenience links: a URL in rendered output, unbound to the
|
||||
//! answer, unsigned, and unaffected if the thing they point at changes or is
|
||||
//! deleted.
|
||||
//!
|
||||
//! This crate closes three specific gaps, each as a checkable property rather
|
||||
//! than an assurance:
|
||||
//!
|
||||
//! 1. **Sources read are recorded.** A [`Citation`] lists every source consulted,
|
||||
//! in read order — the artifact an actions-only log cannot produce.
|
||||
//! 2. **A citation cannot outlive its referent.** Each source is bound by
|
||||
//! **content id**, not by locator. Delete or edit the source and
|
||||
//! [`Citation::verify_source`] fails: the citation becomes *verifiably
|
||||
//! dangling* instead of silently pointing at something that changed underneath
|
||||
//! it. A URL cannot do this; a hash can.
|
||||
//! 3. **A citation says who may read it.** Each source records the **grant it was
|
||||
//! read under**, so [`Citation::unreadable_by_with`] tells a reader exactly
|
||||
//! which sources they are not cleared to open — an authorization decision the
|
||||
//! caller supplies (a true capability-subset check against the reader's held
|
||||
//! authority), rather than handing them a link that 404s or, worse, one that
|
||||
//! works when it should not. [`Citation::unreadable_by`] is the conservative
|
||||
//! exact-grant shorthand.
|
||||
//!
|
||||
//! And the energy: a retrieval costs joules, and the citation carries them, so
|
||||
//! the cost of grounding an answer is accounted like any other work.
|
||||
//!
|
||||
//! **How it composes.** A citation is content-addressed, and its id is the
|
||||
//! retrieval frame's `payload_hash` — so the JCR-1 receipt (`jcp/2`, SPEC §8)
|
||||
//! *commits* it. The receipt proves the retrieval was authorized, gated and
|
||||
//! metered; the citation records what it consulted; the receipt binds the two.
|
||||
//! Neither artifact is trusted on its own:
|
||||
//!
|
||||
//! ```text
|
||||
//! verify the receipt → the retrieval was authorized + metered
|
||||
//! citation.id() == payload_hash → this citation is the one that was sealed
|
||||
//! verify_answer(bytes) → the answer has not been edited since
|
||||
//! verify_source(i, bytes) → source i is intact, not deleted or rewritten
|
||||
//! unreadable_by(grants) → which sources this reader may not open
|
||||
//! ```
|
||||
//!
|
||||
//! Ingest, not compete: the surfaces already cite. What they do not do is make a
|
||||
//! citation *checkable*.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
|
||||
use jcp_schema::{canonical_cbor, ContentId, Did, Frame, FrameKind, SchemaError, Trust};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Errors building or verifying a citation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CiteError {
|
||||
#[error("signature did not verify")]
|
||||
BadSignature,
|
||||
#[error("the citation's signer does not match the claimed reader")]
|
||||
SignerMismatch,
|
||||
#[error("malformed signature bytes: {0}")]
|
||||
SigShape(String),
|
||||
#[error("no such source index: {0}")]
|
||||
NoSuchSource(usize),
|
||||
/// The source's bytes no longer hash to what the citation recorded — it was
|
||||
/// edited or replaced. A *dangling* citation, detected rather than missed.
|
||||
#[error("source {0} does not match the cited content (edited or replaced)")]
|
||||
SourceMismatch(usize),
|
||||
/// The answer's bytes no longer hash to what the citation recorded.
|
||||
#[error("the answer does not match the cited content (edited since)")]
|
||||
AnswerMismatch,
|
||||
#[error(transparent)]
|
||||
Schema(#[from] SchemaError),
|
||||
}
|
||||
|
||||
/// One source consulted while producing an answer.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Source {
|
||||
/// Content id of the source **as it was read**. This is the binding: a
|
||||
/// locator can be reused, redirected or deleted, a hash cannot.
|
||||
pub content: ContentId,
|
||||
/// The grant this source was read under. A verifier checks the read was
|
||||
/// authorized; a reader checks whether they hold equivalent authority.
|
||||
pub grant_id: ContentId,
|
||||
/// Human-facing locator (URI, page id, row id) — **convenience only**, never
|
||||
/// the binding. Omitted when the source is not addressable by name.
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub locator: Option<String>,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
pub fn new(content: ContentId, grant_id: ContentId) -> Self {
|
||||
Source { content, grant_id, locator: None }
|
||||
}
|
||||
pub fn at(mut self, locator: impl Into<String>) -> Self {
|
||||
self.locator = Some(locator.into());
|
||||
self
|
||||
}
|
||||
/// Whether `bytes` are the source as cited.
|
||||
pub fn matches(&self, bytes: &[u8]) -> bool {
|
||||
ContentId::of_bytes(bytes) == self.content
|
||||
}
|
||||
}
|
||||
|
||||
/// The signed provenance of one answer: what was read, under whose authority,
|
||||
/// and at what energy cost.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Citation {
|
||||
/// The principal that performed the retrieval and signs this record.
|
||||
pub reader: Did,
|
||||
/// Content id of the answer this provenance is for.
|
||||
pub answer: ContentId,
|
||||
/// Every source consulted, **in read order**. Order is part of the record:
|
||||
/// it is what makes a retrieval replayable.
|
||||
pub sources: Vec<Source>,
|
||||
/// Joules the retrieval consumed, in µJ — grounding an answer is work, and
|
||||
/// work is accounted.
|
||||
pub energy_uj: u64,
|
||||
/// Freshness/uniqueness so two identical retrievals are distinct records.
|
||||
pub nonce: [u8; 16],
|
||||
}
|
||||
|
||||
impl Citation {
|
||||
pub fn new(reader: Did, answer: ContentId, sources: Vec<Source>, energy_uj: u64, nonce: [u8; 16]) -> Self {
|
||||
Citation { reader, answer, sources, energy_uj, nonce }
|
||||
}
|
||||
|
||||
/// The citation's content id — **this is what a retrieval frame's
|
||||
/// `payload_hash` carries**, so the JCR-1 receipt commits this exact record.
|
||||
pub fn id(&self) -> Result<ContentId, CiteError> {
|
||||
Ok(ContentId::of(self)?)
|
||||
}
|
||||
|
||||
/// Whether `bytes` are the answer as cited — detects an answer edited after
|
||||
/// the fact while still carrying its original provenance.
|
||||
pub fn verify_answer(&self, bytes: &[u8]) -> Result<(), CiteError> {
|
||||
if ContentId::of_bytes(bytes) == self.answer {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CiteError::AnswerMismatch)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether source `i` is intact. **This is the property a URL cannot have:**
|
||||
/// if the source was deleted, edited, or replaced, this fails — the citation
|
||||
/// is verifiably dangling rather than quietly wrong.
|
||||
pub fn verify_source(&self, i: usize, bytes: &[u8]) -> Result<(), CiteError> {
|
||||
let s = self.sources.get(i).ok_or(CiteError::NoSuchSource(i))?;
|
||||
if s.matches(bytes) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CiteError::SourceMismatch(i))
|
||||
}
|
||||
}
|
||||
|
||||
/// The sources this reader may **not** open, by index, decided by an
|
||||
/// `authorizes` predicate the caller supplies.
|
||||
///
|
||||
/// This is the general form, and the correct one: whether a reader may open a
|
||||
/// source is an *authorization* question, and only the caller — who can
|
||||
/// resolve a `grant_id` to its capability and knows the reader's own held
|
||||
/// authority — can answer it. A caller with the enforcement runtime passes a
|
||||
/// predicate that does a true capability-subset check (a reader holding a
|
||||
/// broader grant that covers the source's read is authorized); this crate
|
||||
/// stays a pure provenance record and takes no dependency on the decision
|
||||
/// engine. The predicate returns `true` when the reader **may** open `source`.
|
||||
///
|
||||
/// An answer may legitimately be produced under authority the reader lacks —
|
||||
/// an agent reads under its own grants, not its invoker's — so rather than
|
||||
/// hand over a citation that silently fails (or silently succeeds when it
|
||||
/// should not), this names the gap: a UI can say *"3 of 7 sources are outside
|
||||
/// your access"* instead of rendering a broken link.
|
||||
pub fn unreadable_by_with(&self, mut authorizes: impl FnMut(&Source) -> bool) -> Vec<usize> {
|
||||
self.sources
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| !authorizes(s))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The sources a reader may not open, by the **conservative** rule: a source
|
||||
/// is readable only if the reader holds the *exact* grant it was read under.
|
||||
///
|
||||
/// This never claims access it cannot prove, but it is coarser than real
|
||||
/// authorization: a reader holding a **broader** grant that would in fact
|
||||
/// cover the read is still flagged here, because this compares grant ids by
|
||||
/// identity, not capabilities by subset. For a true check, use
|
||||
/// [`unreadable_by_with`](Self::unreadable_by_with) with a predicate backed by
|
||||
/// the enforcement runtime.
|
||||
pub fn unreadable_by(&self, held: &[ContentId]) -> Vec<usize> {
|
||||
self.unreadable_by_with(|s| held.contains(&s.grant_id))
|
||||
}
|
||||
|
||||
/// Every distinct grant this retrieval exercised.
|
||||
pub fn grants_exercised(&self) -> Vec<ContentId> {
|
||||
let mut v: Vec<ContentId> = self.sources.iter().map(|s| s.grant_id).collect();
|
||||
v.sort();
|
||||
v.dedup();
|
||||
v
|
||||
}
|
||||
|
||||
/// Sign this citation as the reader.
|
||||
pub fn seal(self, reader_sk: &SigningKey) -> Result<SignedCitation, CiteError> {
|
||||
if self.reader != Did::from_verifying_key(&reader_sk.verifying_key()) {
|
||||
return Err(CiteError::SignerMismatch);
|
||||
}
|
||||
let signature = reader_sk.sign(&canonical_cbor(&self)?).to_bytes().to_vec();
|
||||
Ok(SignedCitation { citation: self, signature })
|
||||
}
|
||||
|
||||
/// The retrieval frame whose `payload_hash` is this citation — the frame a
|
||||
/// runtime decides and a `jcp/2` receipt then commits.
|
||||
pub fn frame(&self, grant_id: ContentId, resource: ContentId) -> Result<Frame, CiteError> {
|
||||
Ok(Frame {
|
||||
kind: FrameKind::ToolCall,
|
||||
trust: Trust::Control,
|
||||
grant_id,
|
||||
action: "retrieve".into(),
|
||||
resource,
|
||||
payload_hash: self.id()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A citation signed by the principal that performed the retrieval.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SignedCitation {
|
||||
pub citation: Citation,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Verify a citation's signature against the reader it names. A third party
|
||||
/// trusts the reader's key, not the surface that rendered the answer.
|
||||
pub fn verify(sc: &SignedCitation) -> Result<(), CiteError> {
|
||||
let vk = sc.citation.reader.verifying_key()?;
|
||||
let sig = Signature::from_slice(&sc.signature).map_err(|e| CiteError::SigShape(e.to_string()))?;
|
||||
vk.verify(&canonical_cbor(&sc.citation)?, &sig).map_err(|_| CiteError::BadSignature)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sk(s: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[s; 32])
|
||||
}
|
||||
fn did(k: &SigningKey) -> Did {
|
||||
Did::from_verifying_key(&k.verifying_key())
|
||||
}
|
||||
|
||||
const DOC_A: &[u8] = b"Q3 revenue was 4.2M, up 18% YoY.";
|
||||
const DOC_B: &[u8] = b"Headcount plan: 12 hires in Q4.";
|
||||
const ANSWER: &[u8] = b"Revenue grew 18% and 12 hires are planned.";
|
||||
|
||||
fn cite() -> (Citation, ContentId, ContentId) {
|
||||
let g1 = ContentId::of_bytes(b"grant:finance");
|
||||
let g2 = ContentId::of_bytes(b"grant:people");
|
||||
let c = Citation::new(
|
||||
did(&sk(2)),
|
||||
ContentId::of_bytes(ANSWER),
|
||||
vec![
|
||||
Source::new(ContentId::of_bytes(DOC_A), g1).at("/finance/q3"),
|
||||
Source::new(ContentId::of_bytes(DOC_B), g2).at("/people/plan"),
|
||||
],
|
||||
4_200,
|
||||
[1; 16],
|
||||
);
|
||||
(c, g1, g2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seals_and_verifies_under_the_reader_key() {
|
||||
let (c, _, _) = cite();
|
||||
let sc = c.seal(&sk(2)).unwrap();
|
||||
verify(&sc).unwrap();
|
||||
// A different signer cannot claim to be the reader.
|
||||
assert!(matches!(cite().0.seal(&sk(9)), Err(CiteError::SignerMismatch)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampering_with_the_source_list_breaks_the_signature() {
|
||||
let (c, g1, _) = cite();
|
||||
let mut sc = c.seal(&sk(2)).unwrap();
|
||||
// Quietly add a source the retrieval never read.
|
||||
sc.citation.sources.push(Source::new(ContentId::of_bytes(b"fabricated"), g1));
|
||||
assert!(matches!(verify(&sc), Err(CiteError::BadSignature)));
|
||||
}
|
||||
|
||||
/// THE property a URL cannot have: the cited source is edited or deleted, and
|
||||
/// the citation reports it instead of pointing at whatever is there now.
|
||||
#[test]
|
||||
fn an_edited_source_makes_the_citation_verifiably_dangling() {
|
||||
let (c, _, _) = cite();
|
||||
c.verify_source(0, DOC_A).expect("intact source verifies");
|
||||
// The page is edited after the answer was produced.
|
||||
let err = c.verify_source(0, b"Q3 revenue was 9.9M.").unwrap_err();
|
||||
assert!(matches!(err, CiteError::SourceMismatch(0)));
|
||||
// …and the answer itself is likewise pinned.
|
||||
c.verify_answer(ANSWER).unwrap();
|
||||
assert!(matches!(c.verify_answer(b"Revenue fell."), Err(CiteError::AnswerMismatch)));
|
||||
}
|
||||
|
||||
/// A reader is told which sources they may not open, rather than being handed
|
||||
/// a link that fails — or one that works when it should not.
|
||||
#[test]
|
||||
fn names_the_sources_a_reader_may_not_open() {
|
||||
let (c, g1, g2) = cite();
|
||||
assert_eq!(c.unreadable_by(&[g1, g2]), Vec::<usize>::new(), "full authority reads all");
|
||||
assert_eq!(c.unreadable_by(&[g1]), vec![1], "the people doc is out of reach");
|
||||
assert_eq!(c.unreadable_by(&[]), vec![0, 1], "no authority reads nothing");
|
||||
assert_eq!(c.grants_exercised().len(), 2);
|
||||
}
|
||||
|
||||
/// The tightening: a reader holding a BROADER grant that covers both reads is
|
||||
/// authorized under a real subset check, even though it matches neither
|
||||
/// source's exact grant id — the case the conservative shorthand gets wrong.
|
||||
#[test]
|
||||
fn a_predicate_authorizes_a_broader_grant_that_exact_match_rejects() {
|
||||
let (c, g1, g2) = cite(); // sources read under g1 (finance) and g2 (people)
|
||||
let g_admin = ContentId::of_bytes(b"grant:admin-covers-all");
|
||||
|
||||
// The admin grant equals neither source's grant id, so the shorthand is
|
||||
// pessimistic and flags both sources unreadable.
|
||||
assert_eq!(c.unreadable_by(&[g_admin]), vec![0, 1]);
|
||||
|
||||
// But the caller knows g_admin's capability is a superset of both reads.
|
||||
// The predicate is where that real authorization lives — here a stand-in
|
||||
// for `held_capability.covers(source_read)` via the runtime.
|
||||
let covered = [g1, g2];
|
||||
let authorizes = |s: &Source| covered.contains(&s.grant_id); // g_admin covers these
|
||||
assert_eq!(
|
||||
c.unreadable_by_with(authorizes),
|
||||
Vec::<usize>::new(),
|
||||
"a broader grant covers every source under a true subset check"
|
||||
);
|
||||
}
|
||||
|
||||
/// The composition that matters: the citation's id IS the retrieval frame's
|
||||
/// payload_hash, so a jcp/2 receipt commits this exact provenance record.
|
||||
#[test]
|
||||
fn the_receipt_commits_the_citation() {
|
||||
use jcp_receipt::{extract, seal};
|
||||
use jcp_runtime::Runtime;
|
||||
use jcp_schema::{Capability, Energy, Grant};
|
||||
|
||||
let issuer = sk(1);
|
||||
let reader = sk(2);
|
||||
let resource = ContentId::of_bytes(b"corpus");
|
||||
let grant = Grant {
|
||||
issuer: did(&issuer),
|
||||
subject: did(&reader),
|
||||
capability: Capability::new(resource, ["retrieve"]),
|
||||
budget_uj: 1_000_000,
|
||||
budget_funds: None,
|
||||
caveats: Vec::new(),
|
||||
expiry: 0,
|
||||
parent: None,
|
||||
delegate_depth: None,
|
||||
nonce: [0; 16],
|
||||
};
|
||||
let sg = jcp_grant::issue(&issuer, grant.clone()).unwrap();
|
||||
|
||||
let (c, _, _) = cite();
|
||||
let frame = c.frame(grant.id(), resource).unwrap();
|
||||
let mut rt = Runtime::new();
|
||||
let d = rt.decide(&frame, &sg, did(&reader), Energy::measured(c.energy_uj), 1);
|
||||
assert!(d.is_allow());
|
||||
|
||||
let steward = sk(7);
|
||||
let env = seal(&frame, &grant, &d, &steward, "did:example:steward").unwrap();
|
||||
let core = extract(&env, "did:example:steward", &steward.verifying_key().to_bytes()).unwrap();
|
||||
|
||||
// The receipt commits THIS citation, by content id.
|
||||
assert_eq!(core.payload_hash.as_deref(), Some(c.id().unwrap().to_hex().as_str()));
|
||||
// Any other citation would not match — provenance cannot be swapped.
|
||||
let other = Citation::new(did(&sk(2)), ContentId::of_bytes(b"other"), vec![], 0, [9; 16]);
|
||||
assert_ne!(core.payload_hash.as_deref(), Some(other.id().unwrap().to_hex().as_str()));
|
||||
// …and the joules are on the receipt.
|
||||
assert_eq!(core.cost_uj, 4_200);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue