Skip to content

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

Merged
macanderson merged 2 commits into
mainfrom
feat/consent-scope-receipts
Jul 22, 2026
Merged

feat(consent): egress-scope vocabulary + append-only consent receipts#37
macanderson merged 2 commits into
mainfrom
feat/consent-scope-receipts

Conversation

@macanderson

@macanderson macanderson commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Completes the receipt semantics noted missing in #25.

PR #32 landed the boolean consent gate — ConsentRecord (provider_id, data_flow, granted_scope, granted_at), ConsentStore (record/revoke/get/is_consented/permits), and host rejection of unconsented egress. That answers "is this provider allowed, right now, in this process?" It does not answer the question the issue was actually about: "what left the machine, to whom, who agreed, and when?", asked months later against a durable artifact.

This PR adds the two pieces that make consent an artifact rather than an event.

The egress-scope vocabulary

contextgraph-types/src/scope.rs introduces EgressScope — the four normative base classes as a real type:

Scope Wire string Off-machine?
LocalOnly local-only no
OrgTenant org-tenant yes
ThirdPartyIndex third-party-index yes
ThirdPartyModel third-party-model yes

Extensible by namespaced Custom("vendor:scope-name") scopes, which MUST carry a : with non-empty sides so a custom scope can never collide with — or be spelled as — a base class. Everything but local-only is off-machine, including an unrecognized custom scope: the conservative default is that an unknown destination leaves, so a host never under-gates something it doesn't recognize.

Providers declare scopes in the new DataFlow.egress_scopes. The field is #[serde(default, skip_serializing_if = "Vec::is_empty")], so a provider that declares no scopes produces byte-identical wire output to before — the change is strictly additive and every existing provider keeps working under the legacy boolean gate.

DataFlow::scopes_consistent() holds a provider to a truthful declaration: an off-machine scope alongside egress: false is a contradiction (claiming local posture while naming a destination that leaves), and a non-namespaced custom scope is malformed.

Consent receipts

ConsentReceipt records provider_id, scope, provider_name/provider_version pinned at grant time (so a later rename can't retroactively rewrite what was agreed), grantor (Human(id) | Policy(id) — a named party, not an anonymous "yes"), granted_at, and optional expires_at.

Receipts live in an append-only ledger (ConsentStore::record_receipt): a new grant never edits or erases an old one, so the history of consent is the audit trail. Expiry never prunes it either — an expired receipt stops being live but stays on the record, which is what makes the window of authorized egress itself auditable.

The runtime gate is deliberately presence-based and clock-free (has_receipt), so a query decision never depends on wall-time. Expiry is a first-class property (ConsentReceipt::is_live) that a host enforcing it consults via ConsentStore::live_receipt against its own now.

Host enforcement

ConsentStore::evaluate returns a typed ConsentDecision:

  • off-machine scopes declared → permitted only when every such scope has a receipt; otherwise NeedsReceipts(scopes) naming exactly what lacks one. A boolean ConsentRecord does not satisfy a scope gate — there's a test pinning that specifically.
  • boolean egress only → the pre-scope legacy gate, unchanged.
  • purely local → permitted.

Refusal surfaces as HostError::ConsentScopeRequired { id, scopes }, naming exactly the scopes that would have leaked, and the query payload is never transmitted — the host test asserts the provider was never even called.

Conformance

Both sides of the issue's requirement:

  • Provider declaration — a new consent-scope check validates declared scopes against data_flow.egress, driven by a real --misbehave scope-lie fixture mode that declares third-party-index alongside egress: false. The test asserts the handshake still passes and only the scope check catches the lie, so it's proven to be that check doing the work.
  • Host enforcement — a host test drives a scoped provider through refusal (payload not transmitted, typed error names the scope) and then through acceptance after a receipt is recorded.

Audit scenario

docs/context-reuse.md §3 adds the worked scenario the issue asked for — an auditor asking six months later whether repository snippets reached an external model, answered field-by-field from the persisted ledger with no live process — plus normative requirements C5 (truthful declaration) and C6 (host rejection). schema/contextgraph-envelope.schema.json and examples/reference-messages.json are kept in sync with the types, mirroring how #32 handled it.

Notes for adjacent issues

Verification

cargo fmt --all -- --check     # clean
cargo clippy --workspace --all-targets -- -D warnings   # clean
cargo test --workspace         # all green

https://claude.ai/code/session_01BvcuRRNHvpFSYLVhow4HGb

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

@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

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
macanderson merged commit d229ed9 into main Jul 22, 2026
1 check passed
@macanderson
macanderson deleted the feat/consent-scope-receipts branch July 22, 2026 00:33
macanderson added a commit that referenced this pull request Jul 22, 2026
Resolves the 12 conflict hunks across 7 files. Every conflict was additive —
main's consent/egress-scope track (#35, #36, #37) versus this branch's
normative sweep — so the resolution is a union in each case, with main's
newer normative wording kept where both sides described the same rule.

Resolutions:
- contextgraph-types/src/lib.rs — union of both module sets and re-exports.
- contextgraph-conformance/src/lib.rs — keeps this branch's check_budget
  (§B1/§B3/§B4, strictly stronger than main's inline respects_budget) and
  adds main's check_consent_scopes; the runner already calls both.
- contextgraph-example-docs — union of imports and of the misbehave modes,
  so scope-lie joins the branch's eleven.
- schema — main's egress_scopes + EgressScope $def, rewritten in the
  expanded JSON style this branch normalized the file to.
- host.rs / host consent.rs / reference-messages.json — main's newer text.

Also repairs breakage that predates this merge: the previous merge commit
(10808ed) silently dropped ContextFrame::identity() while keeping the
content_digest field it feeds, so PR 33's head did not compile against its
own tree. identity() is restored and the reference provider now declares a
content_digest. rustfmt applied to main's frame_representation_witness.rs,
which arrived unformatted.

Verified: cargo check/clippy -D warnings clean, fmt clean, schema examples
validate, conformance green 6/6 and red 12/12 misbehave modes caught,
cargo test 136 passing.

Known-red, pre-existing on main and not addressed here:
frame_representation_witness::reference_frame_without_inline_content_deserializes
fails identically on origin/main — ContextFrame.content is a required String,
so a content-less reference frame cannot deserialize. Making it optional is a
normative decision that interacts with §B3 canonical token accounting, so it
is left for the maintainer.
macanderson added a commit that referenced this pull request Jul 22, 2026
…phase 2) (#41)

Extend ContextFrame so a frame states *how* it carries its content, per the CGEP
lifecycle build prompt §"ContextFrame representations". Additive and
backward-compatible: `representation` absent ⇒ `full`, and full/legacy frames are
byte-identical on the wire. Satisfies frame_representation_witness.rs (both cases).

Types (contextgraph-types):
- Representation {full|compact|reference}, ContentFidelity, ContentRef, Transform,
  InlineContentRequirement.
- ContextFrame: `content` → Option (absent for references); adds representation,
  content_fidelity, canonical_content_hash, content_ref, transform,
  minimum_content_fidelity, inline_content_requirement, canonical_token_cost,
  tokenizer_ref. `content_digest` kept as the inline-content hash (the spec's
  content_hash, which feeds FrameId); canonical_content_hash is the full-source
  hash. `full`/`reference` constructors + representation_invariants().
- Negotiation: ContextQuery.representation_preferences (+ select_representation);
  Capabilities.representations + resolve (+ representations_consistent).

token_cost kept required (u32); canonical_token_cost/tokenizer_ref added
additively. Making token_cost optional reopens PR #33's B3 budget-accounting
decision on the same field, so it is deferred to that reconciliation.

Wire + docs deliverables:
- JSON Schema: new $defs + per-representation invariants (allOf if/then); `content`
  no longer globally required.
- examples/reference-messages.json: representation-capable handshake_ack, a query
  with representation_preferences, and compact + reference frames (all validate).
- docs/protocol-surface.md representation section; docs/adr/0005; CHANGELOG.

Also unbreaks `main`: contextgraph-host and -conformance did not compile from a
half-applied #37 merge (missing ConsentReceipt/EgressScope/FrameId/DropReason
imports, a DataFlow literal missing egress_scopes, a CHECK_VERIFY_HONESTY test
import, and a stale check-count assertion). Fixed so the workspace builds and the
full suite passes.

Co-authored-by: macanderson <ops@oxagen.sh>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant