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
Owner

A recent survey of agent platforms established the shape: they 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 read to decide. The incumbents document this exclusion in their own words ("Recording what an agent did during a specific run is not included"). Citations, where they exist, are convenience links: a URL in rendered output, unbound to the answer, unsigned, unaffected if the referent changes or is deleted.

This crate makes a citation checkable — three properties, each a method, each drawn from a gap the field documented against its own interest:

  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 → verifiably dangling rather than silently pointing at whatever is there now. A URL cannot do this; a hash can. verify_answer() pins the answer likewise.
  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 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, don't 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.

The 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, #23) commits it:

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 is unedited
verify_source(i, bytes)       → source i is intact, not deleted/rewritten
unreadable_by(grants)         → which sources this reader may not open

Tested end to end: the sealed receipt's payload_hash equals the citation's id and cannot be swapped. This is the concrete payoff of the payload_hash bump landing first.

Example output

source 0 after an edit:   false  ← dangling detected, not silently wrong
sources this reviewer may not open: [1] (of 2)
  → /people/q4-plan requires authority you do not hold

New crate, additive — no change to existing crates beyond the workspace member list. 44 suites green, clippy clean.

A recent survey of agent platforms established the shape: **they 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 read to decide. The incumbents document this exclusion in their own words ("Recording what an agent did during a specific run is not included"). Citations, where they exist, are convenience links: a URL in rendered output, unbound to the answer, unsigned, unaffected if the referent changes or is deleted. This crate makes a citation **checkable** — three properties, each a method, each drawn from a gap the field documented against its own interest: 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 → *verifiably dangling* rather than silently pointing at whatever is there now. A URL cannot do this; a hash can. `verify_answer()` pins the answer likewise. 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 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, don't 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. ### The 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, #23) **commits** it: ``` 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 is unedited verify_source(i, bytes) → source i is intact, not deleted/rewritten unreadable_by(grants) → which sources this reader may not open ``` Tested end to end: the sealed receipt's `payload_hash` equals the citation's id and cannot be swapped. This is the concrete payoff of the `payload_hash` bump landing first. ### Example output ``` source 0 after an edit: false ← dangling detected, not silently wrong sources this reviewer may not open: [1] (of 2) → /people/q4-plan requires authority you do not hold ``` New crate, additive — no change to existing crates beyond the workspace member list. 44 suites green, clippy clean.
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 <[email protected]>
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 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!32
No description provided.