jcp-cite: retrieval provenance — a checkable record of what an agent READ #32

Merged
dcharlot merged 2 commits from jcp/cite into main 2026-07-20 19:50:45 -04:00
5 changed files with 512 additions and 0 deletions

13
jcp/jcp-rs/Cargo.lock generated
View file

@ -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"

View file

@ -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",

View 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

View 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");
}
}
}

View 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);
}
}