From b2ba03e878a89ace178082b41932fd75d0633fd4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 20 Jul 2026 18:53:40 -0400 Subject: [PATCH 1/2] =?UTF-8?q?jcp-cite:=20retrieval=20provenance=20?= =?UTF-8?q?=E2=80=94=20a=20checkable=20record=20of=20what=20an=20agent=20R?= =?UTF-8?q?EAD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent platforms log actions, not sources. A workspace agent's audit trail records that it posted a report or updated a row, and records nothing about the documents it consulted to decide — so the answer cannot be reconstructed and a reviewer cannot tell grounding from invention. Where citations exist they are convenience links: a URL in rendered output, unbound to the answer, unsigned, and unaffected if the thing it points at changes or is deleted. This crate makes a citation checkable. Three properties, each a method rather than an assurance, drawn from the three gaps a recent survey of the field documented in the incumbents' own words: 1. Sources read are recorded, 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 a locator: edit or delete the source and verify_source() fails, so the citation is verifiably dangling instead of silently pointing at whatever is there now. A URL cannot do this; a hash can. verify_answer() pins the answer the same way. 3. A citation says who may read it. Each source records the grant it was read under, so unreadable_by() names exactly which sources a given reader is not cleared to open — because an agent reads under its own grants, not its invoker's, so a citation can legitimately point at content the reader cannot see. Name the gap rather than hand over a link that fails (or wrongly works). It carries the retrieval's joules, so grounding an answer is accounted like any other work. Composition (the point): a Citation is content-addressed and its id IS the retrieval frame's payload_hash, so the jcp/2 receipt (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, and neither is trusted alone. Tested end to end: the sealed receipt's payload_hash equals the citation's id and cannot be swapped for another. New crate, additive; no change to existing crates beyond the workspace member list. 44 suites green, clippy clean, runnable example. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/Cargo.lock | 13 + jcp/jcp-rs/Cargo.toml | 1 + jcp/jcp-rs/crates/jcp-cite/Cargo.toml | 20 ++ jcp/jcp-rs/crates/jcp-cite/examples/cite.rs | 95 ++++++ jcp/jcp-rs/crates/jcp-cite/src/lib.rs | 333 ++++++++++++++++++++ 5 files changed, 462 insertions(+) create mode 100644 jcp/jcp-rs/crates/jcp-cite/Cargo.toml create mode 100644 jcp/jcp-rs/crates/jcp-cite/examples/cite.rs create mode 100644 jcp/jcp-rs/crates/jcp-cite/src/lib.rs diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 31d29587d..2ba3fb10e 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -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" diff --git a/jcp/jcp-rs/Cargo.toml b/jcp/jcp-rs/Cargo.toml index 65e13434a..fcb6f59b4 100644 --- a/jcp/jcp-rs/Cargo.toml +++ b/jcp/jcp-rs/Cargo.toml @@ -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", diff --git a/jcp/jcp-rs/crates/jcp-cite/Cargo.toml b/jcp/jcp-rs/crates/jcp-cite/Cargo.toml new file mode 100644 index 000000000..16832ec1b --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-cite/Cargo.toml @@ -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 diff --git a/jcp/jcp-rs/crates/jcp-cite/examples/cite.rs b/jcp/jcp-rs/crates/jcp-cite/examples/cite.rs new file mode 100644 index 000000000..f6d9ceb88 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-cite/examples/cite.rs @@ -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"); + } + } +} diff --git a/jcp/jcp-rs/crates/jcp-cite/src/lib.rs b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs new file mode 100644 index 000000000..1a13c2fd8 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs @@ -0,0 +1,333 @@ +//! **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`] tells a reader exactly which +//! sources they are not cleared to open — rather than handing them a link that +//! 404s or, worse, one that works when it should not. +//! +//! 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, +} + +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) -> 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, + /// 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, 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 { + 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 a reader holding `held` grants may **not** open, by index. + /// + /// An answer may legitimately be produced under authority the reader lacks — + /// an agent reads under its own grants, not its invoker's. Rather than hand + /// the reader a citation that silently fails (or silently succeeds when it + /// should not), this names the gap so a UI can say *"3 of 7 sources are + /// outside your access"* instead of rendering a broken link. + pub fn unreadable_by(&self, held: &[ContentId]) -> Vec { + self.sources + .iter() + .enumerate() + .filter(|(_, s)| !held.contains(&s.grant_id)) + .map(|(i, _)| i) + .collect() + } + + /// Every distinct grant this retrieval exercised. + pub fn grants_exercised(&self) -> Vec { + let mut v: Vec = 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 { + 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 { + 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, +} + +/// 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::::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 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); + } +} -- 2.54.0 (Apple Git-157) From 10f3149d0496dd7c0207384ee03842ed2a00051d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 20 Jul 2026 19:10:48 -0400 Subject: [PATCH 2/2] =?UTF-8?q?jcp-cite:=20tighten=20unreadable=5Fby=20?= =?UTF-8?q?=E2=80=94=20real=20authorization=20via=20a=20caller=20predicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged two soft spots; this addresses them per the finding. #1 (fixed) — `unreadable_by` compared grant ids by exact identity, so a reader holding a BROADER grant that actually covers a source's read was still flagged unreadable. Whether a reader may open a source is an authorization question, and only the caller — who resolves a grant_id to its capability and knows the reader's own held authority — can answer it correctly. Added `unreadable_by_with(predicate)`: the general, correct form, where the caller supplies the decision (a true capability-subset check backed by the runtime). `unreadable_by` is retained as the conservative exact-grant shorthand, reimplemented in terms of the predicate, and documented as pessimistic. The crate stays a pure provenance record with no dependency on the decision engine. New test proves a broader grant is authorized under the predicate where exact-match rejects it. #2 (won't fix, by design) — reusing `FrameKind::ToolCall` + `action: "retrieve"` rather than adding `FrameKind::Retrieval` is correct, not a shortcut. FrameKind's axis is message shape (tool|agent × request|reply); a retrieval request is a tool request, and the sources it reads back are ToolResult/Data. Operation identity belongs in the action field, which is where a consumer filters retrievals. A dedicated kind would fold operation semantics into the request/reply axis — a category error — so it is deliberately not added. jcp-cite suites green, clippy clean. Co-Authored-By: Claude Opus 4.8 --- jcp/jcp-rs/crates/jcp-cite/src/lib.rs | 70 +++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 10 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-cite/src/lib.rs b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs index 1a13c2fd8..2b62bd4a6 100644 --- a/jcp/jcp-rs/crates/jcp-cite/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-cite/src/lib.rs @@ -21,9 +21,12 @@ //! 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`] tells a reader exactly which -//! sources they are not cleared to open — rather than handing them a link that -//! 404s or, worse, one that works when it should not. +//! 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. @@ -151,22 +154,45 @@ impl Citation { } } - /// The sources a reader holding `held` grants may **not** open, by index. + /// 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. Rather than hand - /// the reader a citation that silently fails (or silently succeeds when it - /// should not), this names the gap so a UI can say *"3 of 7 sources are - /// outside your access"* instead of rendering a broken link. - pub fn unreadable_by(&self, held: &[ContentId]) -> Vec { + /// 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 { self.sources .iter() .enumerate() - .filter(|(_, s)| !held.contains(&s.grant_id)) + .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 { + self.unreadable_by_with(|s| held.contains(&s.grant_id)) + } + /// Every distinct grant this retrieval exercised. pub fn grants_exercised(&self) -> Vec { let mut v: Vec = self.sources.iter().map(|s| s.grant_id).collect(); @@ -287,6 +313,30 @@ mod tests { 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::::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] -- 2.54.0 (Apple Git-157)