Skip to content

feat: context-reuse guarantees — deterministic composition, usage reports, consent scopes, context/verify (#23–#26) - #32

Merged
macanderson merged 3 commits into
mainfrom
feat/composition-usage-consent-verify
Jul 21, 2026
Merged

feat: context-reuse guarantees — deterministic composition, usage reports, consent scopes, context/verify (#23–#26)#32
macanderson merged 3 commits into
mainfrom
feat/composition-usage-consent-verify

Conversation

@macanderson

@macanderson macanderson commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Implements the four interlocking context-reuse guarantees that make reusing retrieved context across a session's turns cache-friendly, auditable, and safe from serving stale evidence. They share one spine — the stable frame identity from #23 — and grow the contextgraph/1 family purely additively (new optional fields, a capability-gated method, host-side artifacts). No flag day.

Status: draft, in progress. #23 and #24 are complete and green; #25 and #26 are being built on this branch in dependency order.

#23 — Deterministic composition (already landed on branch)

Stable FrameId identity (provider id, frame id, content digest) + canonical ordering, so an unchanged context set renders byte-identically across turns and hosts, keeping prompt-cache prefixes stable. Reference host compose_context + round-trip test.

#24 — Usage report envelope ✅

Host-side UsageReport / ProviderUsage / ServedFrame in contextgraph-types (zero new deps): a per-request roll-up of frame costs, itemized by #23 identity so an auditor can walk from a billed total to the exact frames. FanOut::usage_report(&query, as_of) produces it; a conformance case drives the real fixture and re-sums independently to prove the arithmetic identity (U1). Docs §2 maps it into a metering → ClickHouse → Stripe pipeline.

#25 — Consent scopes + receipts (in progress)

Closed egress-scope vocabulary + append-only consent receipts; normative host rejection of unconsented egress.

#26 — context/verify (in progress)

Pull-based per-frame revalidation (valid | stale | gone | unknown), no frame bodies on the wire; host eviction + re-query fallback.

Gates run per issue: cargo fmt --all --check, cargo clippy --workspace --all-targets -D warnings, cargo test --workspace.

Closes #23
Closes #24
Closes #25
Closes #26

… frame identity + canonical ordering (#23)

Introduces the frame-identity spine the context-reuse guarantees share and the
reference host's deterministic composer, so an unchanged context set renders
byte-identically across turns and hosts — keeping prompt-cache prefixes stable
by construction instead of forfeiting them to arrival-order composition.

contextgraph-types:
- new `identity` module: `FrameId { provider_id, frame_id, content_digest }`,
  the (provider, frame, digest) triple; derived `Ord` IS the canonical
  composition order (provider, then frame, then digest). Zero new deps.
- `ContextFrame.content_digest: Option<String>` — provider-declared, opaque
  (never host-computed, to keep non-Rust providers interoperable). Absent ⇒
  the frame is unverifiable and a host re-queries it.
- `ContextFrame::identity(provider_id)`, `canonical_order()`.

contextgraph-host:
- new `compose` module: `compose_context()` renders a frame set in canonical
  order, excluding score/token_cost (relevance/cost must not bust the prefix),
  de-duping identical identities, fencing content as quoted material.
- `FanOut::accepted_with_provider()` + `FanOut::compose()`; a host-side
  round-trip test proves two fan-outs over the same set compose to identical
  bytes.

schema: `content_digest` added to the `ContextFrame` definition.
docs: new `docs/context-reuse.md` §1 (normative identity + ordering, cache-
economics rationale appendix); protocol-surface conformance rows D1–D2, index
+ stability notes on the additive, no-flag-day family growth.

Claude-Session: https://claude.ai/code/session_01BvcuRRNHvpFSYLVhow4HGb
Signed-off-by: macanderson <mac@oxagen.sh>
…ost roll-up for metering (#24)

A host that meters context into a billing system (usage events → ClickHouse →
Stripe) needs an aggregate the protocol didn't give it: which providers served
how many frames, at what token cost, against which budget. Without a standard
shape, every host invents one and context cost stays unauditable one level up
from the wire. This adds that shape as a host-side artifact — no new envelope
variant, no new required field.

contextgraph-types:
- new `usage` module: `UsageReport { budget_requested, budget_consumed, as_of,
  providers }`, `ProviderUsage { provider_id, frames_served, frames_rejected,
  token_cost, served_frames }`, and `ServedFrame { frame: FrameId, token_cost }`.
  Zero new deps (serde only). Each served frame is itemized by its #23 stable
  identity AND declared cost, so the report is self-verifying: totals re-sum from
  the frames they itemize (`is_consistent()`), and an auditor can walk from a
  billed total back to the exact frames.

contextgraph-host:
- `FanOut::usage_report(&query, as_of)` rolls a fan-out up into a report:
  accepted frames itemized, a budget lie's dropped frames counted as rejected,
  a consent-gated/failed provider contributing nothing. `budget_requested` is
  the query's `max_tokens`; `as_of` is the host-supplied accounting snapshot
  (the report's own clock, not the query's bi-temporal `as_of`).

conformance: new `usage_report` integration test drives the real
`contextgraph-example-docs` fixture, then re-sums the accepted frames
INDEPENDENTLY and asserts equality with the host's report — the arithmetic
identity (U1) proven against a live provider, not the builder.

docs: context-reuse.md §2 (report shape, arithmetic identity, worked
metering-pipeline example mapping into ClickHouse + Stripe, U1 conformance row).

Note: `frames_rejected` folds in verification-driven rejections once #26 lands;
`token_cost` semantics pin in #8.

Closes #24

https://claude.ai/code/session_01BvcuRRNHvpFSYLVhow4HGb

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@macanderson
macanderson marked this pull request as ready for review July 21, 2026 22:00

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @macanderson, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@macanderson
macanderson merged commit 9238eee into main Jul 21, 2026
1 check passed
@macanderson
macanderson deleted the feat/composition-usage-consent-verify branch July 21, 2026 22:00
macanderson added a commit that referenced this pull request Jul 22, 2026
…#37)

* feat(consent): egress-scope vocabulary + append-only consent receipts

Completes the receipt semantics noted missing in #25. PR #32 landed the
boolean `ConsentRecord`/`ConsentStore` gate; this adds the normative scope
vocabulary and the durable audit artifact the acceptance criteria called for.

- `contextgraph-types/src/scope.rs`: `EgressScope` — the four normative base
  classes (local-only, org-tenant, third-party-index, third-party-model) plus
  namespaced `vendor:scope` extensions. Flat-string wire form; an unrecognized
  scope is conservatively off-machine so a host never under-gates.
- `DataFlow.egress_scopes` (optional, omitted when empty, so the pre-scope wire
  form is unchanged) + `scopes_consistent()` — an off-machine scope alongside
  `egress: false` is a lie a host rejects at the handshake (C5).
- `ConsentReceipt` with `grantor` (Human | Policy), `granted_at`, optional
  `expires_at`, and provider identity pinned at grant time. Stored in an
  append-only ledger: a new grant never edits or erases an old one, and expiry
  never prunes the audit trail.
- `ConsentStore::evaluate` -> `ConsentDecision` scope gate: a provider is
  permitted only when every off-machine scope has a receipt. A boolean
  `ConsentRecord` does not satisfy a scope gate. Refusal is the typed
  `HostError::ConsentScopeRequired` naming exactly what would have leaked;
  the payload is never transmitted (C6).
- `consent-scope` conformance check + a `--misbehave scope-lie` fixture mode
  proving the suite catches a provider lying about its egress posture.
- Schema, reference messages, and docs kept in sync with the types.

Claude-Session: https://claude.ai/code/session_01BvcuRRNHvpFSYLVhow4HGb

* refactor(consent): move ConsentReceipt + Grantor into contextgraph-types

Issue #25's acceptance criterion says "scope vocabulary + receipt types in
contextgraph-types". They were in contextgraph-host on the rationale that a
receipt is a host-side artifact — but `UsageReport` (#24) is *equally*
host-side (its own module doc says "a host-side artifact, not a wire message")
and still lives in contextgraph-types, because it is a protocol-defined shape.
A receipt is the same category: any host in any language claiming the consent
guarantee must produce it, and an auditor reading a persisted ledger must be
able to parse it without depending on one host implementation.

`ConsentStore` / `ConsentDecision` — the ledger and gate that *consume*
receipts — stay in contextgraph-host. No dependency cycle: the receipt touches
only `EgressScope` and `ProviderInfo`, both already in types.

The receipt's own serde round-trip tests move with the type, and gain cases
for policy-vs-human grantor distinctness, omitted-expiry, and the expiry
boundary instant. The store keeps a test that its receipt ledger survives the
persistence round-trip.

Claude-Session: https://claude.ai/code/session_01BvcuRRNHvpFSYLVhow4HGb
macanderson added a commit that referenced this pull request Aug 1, 2026
…og, and backfill the gaps (#73)

CHANGELOG.md relied on every PR remembering to write its own [Unreleased]
entry. An audit of the 22 merges since v0.1.0 whose numbers never appear
in the file found 9 user-visible changes with no entry at all — including
the OCP -> CGP rename itself, the repo's biggest breaking change. The
Unreleased section had also accumulated duplicate category headings
(two '### Added', two '### Changed', two '### Fixed') from PRs appending
their own blocks.

Go-forward: .github/workflows/changelog.yml watches pushes to main. When
a merge lands without touching CHANGELOG.md, it drafts the missing
entries from the merge's actual diff (.github/scripts/changelog-ai.sh,
Vercel AI Gateway, degrade-open: no key or a failed call is a log line,
never a red check) and proposes them as a bot/changelog PR — a draft for
human review, never a direct push. The gap is measured from the last
commit that touched CHANGELOG.md, so merging the bot PR resets it, and
each run regenerates the whole current gap, so a superseded bot PR loses
nothing.

Backfill: entries for the rename (#1), frame identity/composition/usage
reports (#32), golden wire fixtures (#35), ADR 0007 (#61, #27),
composition conformance + the canary scheduled-run gate (#70) — written
from each PR's diff, in the file's existing voice. Three further audit
hits (#68, #69, #71) turned out to be already covered by newer entries
and were left alone. Duplicate category headings merged: one heading per
category, Keep-a-Changelog order.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment