feat(consent): egress-scope vocabulary + append-only consent receipts#37
Merged
Conversation
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
There was a problem hiding this comment.
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.rsintroducesEgressScope— the four normative base classes as a real type:LocalOnlylocal-onlyOrgTenantorg-tenantThirdPartyIndexthird-party-indexThirdPartyModelthird-party-modelExtensible 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 butlocal-onlyis 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 alongsideegress: falseis a contradiction (claiming local posture while naming a destination that leaves), and a non-namespaced custom scope is malformed.Consent receipts
ConsentReceiptrecordsprovider_id,scope,provider_name/provider_versionpinned 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 optionalexpires_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 viaConsentStore::live_receiptagainst its ownnow.Host enforcement
ConsentStore::evaluatereturns a typedConsentDecision:NeedsReceipts(scopes)naming exactly what lacks one. A booleanConsentRecorddoes not satisfy a scope gate — there's a test pinning that specifically.egressonly → the pre-scope legacy gate, unchanged.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:
consent-scopecheck validates declared scopes againstdata_flow.egress, driven by a real--misbehave scope-liefixture mode that declaresthird-party-indexalongsideegress: 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.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.jsonandexamples/reference-messages.jsonare kept in sync with the types, mirroring how #32 handled it.Notes for adjacent issues
ConsentScopeRequiredcarries an inline note that its stablecodestring slots in alongsideConsentRequiredwhen Structured error codes on the wire #9 lands. Not assigned here, to avoid inventing a code Structured error codes on the wire #9 will have to reconcile.ConsentReceipt/Grantorlive incontextgraph-types. A receipt never appears on the wire, but it followsUsageReport(Usage report envelope: a normative per-request roll-up of frame costs for metering and billing export #24) — whose own module doc calls it "a host-side artifact, not a wire message" and which lives in types anyway, because it is a protocol-defined shape.ConsentStore/ConsentDecision, the ledger and gate that consume receipts, stay incontextgraph-host.Verification
https://claude.ai/code/session_01BvcuRRNHvpFSYLVhow4HGb