Runtime Project Membership Presentation Contract (v1) — GAT-241
Status: Implemented — Gateway candidate/bootstrap path GAT-221; Viper presenter GAT-227; historical resolver baseline GAT-228 — updated 2026-07-10
Implements: the “Viper → Gateway request contract” stage of docs/rfcs/rfc-runtime-project-decision-path.md
Implemented by: GAT-227 (Viper presenter), GAT-228 (historical Gateway resolver baseline), GAT-221 (current candidate + project snapshot bootstrap path)
Related: docs/rfcs/rfc-governance-studio-adapter-integration.md §9 (credential shape, GAT-208), llm-gateway/docs/verification.md (signing profile viper.v1)
This document is normative for how an agent presents project membership evidence on a Gateway-bound request, how Gateway core binds that presentation to one non-authoritative project candidate, and how the candidate’s pinned snapshot authorizes membership before authoritative project context exists. It does not cover credential issuance (integrity service), evidence submission (control-plane adapter API), or project trust / ProjectComplianceV1 (GAT-242).
RFC 2119 keywords (MUST/SHOULD/MAY) are used normatively.
1. Model
Section titled “1. Model”A membership credential is a ProjectMembershipV1 verifiable credential issued by the integrity service (POST /gov/v1/identity/membership/{project_uuid}): subject = agent DID, credentialSubject.identity = {type: "ProjectMembershipV1", project: "urn:uuid:…", role: […]}. Issuance returns {credential, credentialId, sourceCid}.
A presentation is a by-reference claim on a signed request: the agent names the credential by CID and (optionally) names the project it is operating in. The Gateway resolves the presentation against blobs and stored evidence it already holds — the request path never calls an external service.
Evidence is the Gateway’s validated index of membership credentials (project_membership_evidence, populated via POST /v1/projects/{uuid}/memberships on the control-plane, normally driven by Governance Studio).
Design invariants (from the decision-path RFC):
- Identity is the RFC 9421
keyid(agent DID). The presentation binds to it; it never carries identity itself. - Credential bytes never travel in headers, logs, or audit rows — only CIDs, URNs, IDs, and statuses.
- Trust in a presentation header comes exclusively from its inclusion in the request signature.
2. Headers
Section titled “2. Headers”Two request headers, sent by Viper on gateway-bound proxy/relay requests. Both are optional individually; §4 defines the valid combinations.
The canonical credential transport is the signed X-Viper-Credential-CID list. Viper automatically merges the registered agent-identity VC CID with configured project-membership VC CIDs; callers do not configure the agent VC in project_presentations. The Gateway’s identity plugin selects and validates the agent VC, while the project binder interprets only ProjectMembershipV1 candidate fields. X-Viper-Project-URN is only a selector/disambiguator when the list or stored evidence leaves more than one project candidate.
2.1 X-Viper-Credential-CID
Section titled “2.1 X-Viper-Credential-CID”- Value: a canonical list of CIDv1 strings identifying credential blobs. The list syntax is one header field occurrence with 1-8 CIDs, separated by a literal comma (
,), no spaces, no empty items, no duplicates, and CIDs sorted lexicographically by byte value. Example:baga6yaq...,baga6ybr.... CIDs do not contain commas, so the grammar is unambiguous. - Each CID MUST identify a JCS-canonicalized VC blob:
CIDv1(multicodec = JSON_JCS, multihash = BLAKE3(JCS(credential JSON))). For a ProjectMembershipV1 credential, this is exactly how the integrity service computessourceCidat issuance, so a conforming value equals thesourceCidthe Studio submitted as evidence (project_membership_evidence.source_refs.source_cid). The Gateway exploits this: evidence can be matched by CID without fetching or re-validating the blob on the hot path. - The singular header name is intentional even though its value is a bounded list. The header MUST appear at most once, with exactly one canonical value (no folding).
- If present, the header MUST be a covered component of the request signature (§3).
- Referenced blobs SHOULD be available in the Gateway blob store (§5) — uploaded by Viper via the existing signed
PUT /v1/blobs/{cid}, or already known to the Gateway via evidence submission. The identity plugin selects a non-membership agent VC from this list. The project binder only interprets ProjectMembershipV1 candidate fields; other VC types are ignored for project candidate selection.
X-Viper-Verifiable-Credential-CID is deprecated. During migration the Gateway MAY accept that signed single-CID header only when X-Viper-Credential-CID is absent. A request containing both is invalid. Viper MUST emit only X-Viper-Credential-CID.
2.2 X-Viper-Project-URN
Section titled “2.2 X-Viper-Project-URN”- Value: the canonical project URN,
urn:uuid:{project_uuid}, lowercase UUID. (Governance-backend projects carry onlyid+uuid; the URN form is canonicalized here and ingateway_project_references.project_urn.) - Declares which project the agent is operating in for this request when the membership credential list or stored evidence has more than one project candidate. It is a selector, not the source of membership authority.
- MUST appear at most once, with exactly one value.
- If present, the header MUST be a covered component of the request signature (§3).
- REQUIRED when the agent presents or has stored evidence for memberships in more than one project and wants Gateway to resolve one project context. Otherwise OPTIONAL.
At most one non-authoritative project candidate is selected per request. Presenting multiple ProjectMembershipV1 credentials is allowed in the credential list, but core MUST select exactly one candidate or return project_context_ambiguous; it MUST NOT merge roles across projects. Authoritative context is a later typed bootstrap result (§4.4).
2.3 Header constants
Section titled “2.3 Header constants”Rust: declared alongside STATE_CID_HEADER in viper/src/proxy/server.rs (or the shared header module once GAT-246 lands). Go: alongside the existing headerViper* constants; promoted to shared/servicekit/httpx under GAT-246.
3. Signature coverage
Section titled “3. Signature coverage”The viper.v1 profile keeps its version but tightens credential-header profiling. X-Viper-Credential-CID, and the deprecated header when used, MUST be both present and covered: a present-but-uncovered credential header or a covered-but-absent credential header is profile_invalid. This is required because the unified list carries the agent identity credential as well as project evidence. X-Viper-Project-URN coverage is enforced by the non-configurable project binder whenever project context is enabled.
Viper covered-component order (deterministic, extends covered_components in viper/src/proxy/server.rs):
@method@target-urix-viper-state-cidx-viper-noncex-viper-timestampcontent-digest[x-viper-identity-cid] (existing, conditional)[x-viper-credential-cid] (conditional: iff one or more credentials are present)[x-viper-project-urn] (NEW, conditional: iff header present)[x-viper-upstream-url] (existing, relay mode only, always last)For X-Viper-Project-URN, present-but-uncovered remains a non-configurable presentation error: Gateway core MUST discard the value, record project_presentation_unsigned, and fail closed whenever project context is enabled. Gateway rollout observe does not weaken core protocol/binding failures; only builtin.project_membership.mode=observe may turn a membership-policy denial into a non-authoritative would-deny (§7).
4. Presentation forms and gateway resolution
Section titled “4. Presentation forms and gateway resolution”The Gateway binds exactly one of four presentation forms to at most one candidate, then selects and runs that candidate’s membership policy. Candidate binding is deterministic; given the same headers and evidence state, the same candidate or core failure results.
Let keyid = the verified request signer DID, C = X-Viper-Credential-CID, U = X-Viper-Project-URN.
| Form | C | U | Resolution |
|---|---|---|---|
| A. Credential list + selector | ✓ | ✓ | Bind ProjectMembershipV1 evidence from the list (§4.1) and select candidate U. Evidence for other projects without a match is project_membership_project_mismatch. If no list evidence matches, carry stored evidence constrained by U; an empty set still permits U to select the policy whose membership_required decides authorization. |
| B. Credential list only | ✓ | — | Bind membership evidence from the list. Exactly one distinct project → candidate from identity.project; >1 → project_context_ambiguous; no membership evidence → implicit stored-evidence candidate selection. |
| C. Selection-only | — | ✓ | Select U as the non-authoritative candidate and attach stored evidence for subject_did = keyid AND project_urn = U, including non-valid rows. Empty evidence does not grant access; bootstrap policy decides whether it is required. |
| D. Implicit | — | — | Read stored evidence for subject_did = keyid. 0 rows → projectless environment-default behavior. 1 distinct project URN → candidate with same-project evidence. >1 distinct project URNs → project_context_ambiguous. |
Viper SHOULD send form B when configured with exactly one membership credential, form A when configured with multiple membership credentials and a selected project, and form C when configured with only a project selection. Forms B and D exist for graceful degradation and legacy agents.
4.1 Credential-list resolution (forms A/B)
Section titled “4.1 Credential-list resolution (forms A/B)”- Parse and bound the list: core MUST reject a malformed, duplicate, unsorted, or over-limit credential list with
project_credentials_malformed. Non-membership VC types may appear in the list; they are ignored by project candidate binding after their type is known. - Evidence fast path: for each CID in the list, the evidence store MUST expose
GetBySourceCID(subjectDID, sourceCID)backed by an index onsubject_didandsource_refs->>'source_cid'. If it returns one row forsource_refs.source_cid = CIDandsubject_did = keyid, the Gateway MAY use that row’s validated result instead of re-validating the blob, but it MUST re-evaluatevalid_from/valid_untilagainst the current clock and map stored or recomputed status through §4.2. If the lookup returns conflicting rows for the samesubjectDID + sourceCID(differentcredential_id,project_urn, issuer, roles, or status facts), the fast path MUST fail withproject_membership_evidence_conflict; it MUST NOT pick one arbitrarily. - Blob path: for list CIDs not resolved by evidence, fetch the blob by CID from the gateway blob store (
gateway_blobs). Missing blobs are counted as unresolved. If neither signedU, CID-bound membership fields, nor stored evidence can safely select a project candidate, core returnsproject_membership_unresolvable; otherwise the unresolved count is carried to that project’s bootstrap policy. Because the blob store accepts caller-provided keys, core MUST recomputeCIDv1(JSON_JCS, BLAKE3(JCS(credential JSON)))from fetched bytes and compare it to the list CID before trusting candidate fields. Mismatch →project_membership_cid_mismatchif the body is or claims to be ProjectMembershipV1; non-membership credentials are ignored. WhenUis present, core selects candidates whoseidentity.projectequalsUbefore treating other projects as mismatches. - After CID/body and subject/project binding, core MUST rejoin stored evidence by
credential_id + project_urn + subject_didwhen such a row exists and carry it alongside the raw credential.builtin.project_membershipapplies stored terminal revocation/status plus current validity-window policy. Storedrevoked,invalid, oruntrusted_issuerstatus wins over a freshly valid blob. - Core MUST bind
credentialSubject.idtokeyid(project_membership_subject_mismatchotherwise), canonicalize the credential project, and enforce selector/project agreement. It then carries the raw CID-bound credential and/or stored evidence into the selected project’s typed bootstrap input.builtin.project_membershipinvokesservicekit/projectmembership.ValidateCredentialwith the snapshot’s trusted issuers and the core-bound subject/project. Proof/issuer/status/validity/role results authorize only through the typed bootstrap decision.
4.1.1 Stored evidence candidate selection (forms C/D and fast-path rows)
Section titled “4.1.1 Stored evidence candidate selection (forms C/D and fast-path rows)”Stored-evidence candidate binding never filters only to validation_status = 'valid' before deciding whether evidence exists. It first finds candidate rows for the signer, optionally constrained by the signed project URN. An empty candidate evidence set is carried to builtin.project_membership, which returns project_membership_missing only when its membership_required policy requires evidence.
For a chosen subject_did + project_urn, core carries each candidate’s stored validation_status, credential_status, valid_from, and valid_until into typed bootstrap. The plugin evaluates current status and maps non-current rows to the §4.2 reason codes instead of treating them as absent.
Core orders same-project evidence deterministically and the plugin evaluates candidates in that order; roles are never merged across credentials. The v1 tie-break is:
- highest
last_verified_atwith NULLS LAST; - latest
valid_untilwith NULLS LAST; - latest
observed_at(refreshed when evidence is submitted or refreshed); - lexicographically greatest
credential_id.
4.2 Validation outcome mapping
Section titled “4.2 Validation outcome mapping”Inside builtin.project_membership, projectmembership.ValidationStatus maps onto configurable policy reason codes:
| ValidationStatus | Reason code |
|---|---|
valid | — (context established) |
expired | project_membership_expired |
not_yet_valid | project_membership_not_yet_valid |
revoked | membership_revoked |
invalid | project_membership_invalid |
untrusted_issuer | project_membership_untrusted_issuer |
Additional core codes: project_presentation_unsigned (§3), project_credentials_malformed (§4.1), project_membership_cid_mismatch, project_membership_evidence_conflict, project_membership_subject_mismatch, project_membership_project_mismatch, project_context_ambiguous, project_unknown, and project_resolver_error. project_membership_missing and candidate-scoped project_membership_unresolvable are bootstrap-policy outcomes; an unresolvable list with no safely selectable project remains a core failure because no policy can be selected.
Additional storage / binding codes: project_membership_cid_mismatch (blob body recomputes to a different JCS CID than the signed header) and project_membership_evidence_conflict (stored evidence has conflicting rows for a supposedly unique lookup).
Revocation in v1 is the stored status (control-plane refresh); no live status-list call is made in the request path (decision-path RFC §1.5).
4.3 Bound project candidate
Section titled “4.3 Bound project candidate”GAT-221 separates presentation binding from authorization. Forms A–D first produce a non-authoritative candidate consumed only for project snapshot selection:
type ProjectCandidate struct { ProjectURN string SignerDID string Source string Presented bool Unresolved int Evidence []projectmembership.BootstrapEvidence}Core establishes the candidate only after signature coverage, canonical bounded list parsing, current/legacy mutual exclusion, CID/body binding, signer/credential-subject binding, deterministic selection, and project-reference validation. Issuer trust, required membership, accepted status, expiration/revocation, roles, and membership observe/enforce are not candidate-selection inputs. Selecting the candidate’s snapshot is policy selection and MUST NOT grant project access or populate authoritative project.urn.
4.4 Snapshot bootstrap and authoritative context
Section titled “4.4 Snapshot bootstrap and authoritative context”When a candidate exists, Gateway MUST resolve the active snapshot for exactly (environment, candidate.project_urn) with environment fallback disabled. Missing or unmaterializable project policy fails closed. The immutable compiled snapshot is pinned for the request.
Every project snapshot MUST contain exactly one enabled premade builtin.project_membership host entry at project.bootstrap. Its configuration owns membership_required, trusted_issuers, accepted_statuses, enforce_expiration, enforce_revocation, allowed_roles, required_roles, and mode=observe|enforce. The plugin has no outbound HTTP capability and reuses shared/servicekit/projectmembership rather than defining another credential representation.
The plugin returns projectmembership.BootstrapDecision. Gateway validates its project URN and subject DID against the bound candidate and verified signer. Only Allow=true, EstablishContext=true, no would-deny, and no reason code may become authoritative projectctx.Context. Generic HookDecision.ContextWrites can never establish membership. Ordinary project hooks do not run until that typed success. Observe-mode membership failures may allow transport to continue, but they establish no project context and execute no ordinary project plugins.
The same snapshot ID/version remains pinned for project.bootstrap, proxy.pre, proxy.post, exchange audit attribution, and request.end, even when activation changes concurrently.
5. Blob availability
Section titled “5. Blob availability”For forms A/B, Viper SHOULD make each locally held credential blob in X-Viper-Credential-CID resolvable before or alongside first use:
- Serialize the credential with JCS (
serde_jcs), store viablobs::put(bytes, multicodec::JSON_JCS)— notRAW_BINARY(which the state-attestation path uses) — so the CID reproduces integrity’ssourceCid. - Upload via the existing signed blob path (
sync_attestation_blobs→PUT {gateway}/v1/blobs/{cid}), deduped by the existinguploaded_blob_cidsset. - The pinned
integritycrate exposesmulticodec::JSON_JCS; GAT-227 uses it without a dependency bump.
GAT-227 configures project presentations per app identity under project_presentations.<app> in Viper’s local config.toml. credential_files contains paths to ProjectMembershipV1 JSON (relative paths resolve below the Viper config directory); Viper validates them against the app DID, JCS-canonicalizes them, stores them in the local blob store, and uploads them before first use. credential_cids contains durable JSON_JCS CID references for membership credentials whose CIDs are already known. When such a blob is also local, Viper validates and uploads it; otherwise Viper sends the reference and relies on Gateway indexed evidence or an already available blob. project_urn is an optional selector, not membership authority. Multiple opaque references require a selector so Viper does not emit a potentially ambiguous form B. The registered agent VC is runtime DID state and is merged automatically; it MUST NOT be duplicated in this configuration. The 8-CID limit applies to the aggregate list, including that automatic agent VC.
The Gateway MUST NOT fetch credential blobs from the integrity service in the request path (those endpoints require a bearer token the Gateway does not hold; the boundary is intentional).
6. Header hygiene
Section titled “6. Header hygiene”- All gateway-internal
X-Viper-*headers, includingX-Viper-Credential-CID, the deprecated credential header, andX-Viper-Project-URN, MUST be stripped before any downstream relay or LLM-provider request. Relay routing may consumeX-Viper-Upstream-URLinternally, but it MUST NOT forward that header either. - Protected hook context and authoritative
project_urnaudit fields MUST be populated only from validated typed bootstrap success. A separateproject_candidate_urnaudit field MAY record a safely attributable signed candidate before authorization. If credential/project headers are unsigned or cannot be safely bound, hooks see no attacker-supplied project selector and both candidate/authoritative project fields remain empty. - Raw audit header capture MUST redact or omit all
X-Viper-*header values before persistence. Audit rows recordproject_urn,membership_credential_id,membership_issuer_did, statuses, and reason codes only after resolution — never credential JSON and never discarded unsigned project header values (decision-path RFC §1.9).
7. Mode interaction and error surface
Section titled “7. Mode interaction and error surface”Gateway config project_context.mode (LLM_GATEWAY_PROJECT_CONTEXT_MODE) remains the rollout/kill switch ∈ disabled | observe | enforce, default disabled. Both enabled values enforce the non-configurable presentation/binding core. Membership policy observe/enforce is snapshot-owned by builtin.project_membership.mode.
disabled | enabled (observe or enforce) | |
|---|---|---|
| Core binder runs | no | yes |
| Core protocol/binding failure | legacy behavior | fail-closed 403 |
| Candidate selects project policy | no | yes, no environment fallback |
| Membership-policy behavior | n/a | selected plugin’s mode |
| Authoritative context | none | only typed bootstrap success |
Denials use the existing local-deny surface: HTTP 403, body {"code": "<reason_code>", "message": "...", "correlation_id": "..."} (httpx.ErrorResponse shape), audit route_type = local_deny, DecisionCode = <reason_code>. Whether selector-only evidence is allowed is determined by that project’s membership_required; the signed selector can choose policy but cannot bypass bootstrap.
Requests with neither header and no stored evidence are projectless: they pin the environment-default snapshot and skip project bootstrap. An implicit stored membership still creates a candidate and therefore requires the project snapshot/bootstrap path.
8. Versioning
Section titled “8. Versioning”- This contract is versioned by the header set + semantics; call it
project-presentation.v1. It rides theviper.v1signing profile unchanged. - Absence of both headers is a valid v1 presentation (form D). Legacy Vipers are therefore v1-conformant by default.
- A future v2 (multiple simultaneous project contexts, embedded presentations/JWS, delegation, or credential-purpose metadata) gets new headers or explicitly versioned list semantics; v1 headers keep their meaning.
llm-gateway/docs/verification.mddocuments the implemented GAT-221 candidate/bootstrap behavior and references this document.
9. Worked example
Section titled “9. Worked example”Issuance (Studio → integrity, already live): agent DID did:key:z6MkrEy…, project 7f0e6f3a-…, roles ["automation-agent"] → {credentialId: "urn:uuid:0d9c…", sourceCid: "baga6yaq…"}, evidence indexed via control-plane.
Request (form B):
POST /v1/messages HTTP/1.1Host: gateway.internal:10000X-Viper-State-CID: bafkr4i…X-Viper-Nonce: 8c7c1c1e-6b0a-4d5e-9f0e-2a4b6c8d0e1fX-Viper-Timestamp: 2026-07-06T17:03:12.412ZContent-Digest: sha-256=:…:, blake3=:…:X-Viper-Credential-CID: baga6yaq…Signature-Input: sig=("@method" "@target-uri" "x-viper-state-cid" "x-viper-nonce" "x-viper-timestamp" "content-digest" "x-viper-credential-cid");created=1783184592;keyid="did:key:z6MkrEy…";alg="ed25519"Signature: sig=:…:Gateway: verify → core binds the signed list/evidence to candidate urn:uuid:7f0e… → resolve and pin only that project’s active snapshot → run its mandatory typed builtin.project_membership bootstrap → plugin applies issuer/status/validity/revocation/role policy → Gateway validates typed project + subject → authoritative context {ProjectURN: urn:uuid:7f0e…, Established: true, Source: header_evidence} → pinned ordinary hooks see project.urn and project.roles → audit separately records candidate, establishment, snapshot ID/version/scope, bootstrap outcome, and final policy decision.
10. Conformance checklist
Section titled “10. Conformance checklist”Viper (GAT-227, implemented): sends at most one value per header; automatically merges its registered agent VC CID with configured project-membership CIDs; covers the canonical, sorted, deduplicated X-Viper-Credential-CID value in the signature; uploads locally held credential blobs before/with first use; and sends X-Viper-Project-URN whenever configured with multiple membership projects and a selected project.
Gateway (GAT-228 + GAT-221): ignores project presentation in disabled; in enabled modes rejects uncovered/malformed/conflicting presentation in core; binds forms A–D deterministically to a non-authoritative candidate; uses indexed GetBySourceCID(subjectDID, sourceCID) and recomputes blob JCS CIDs; enforces subject/project binding unconditionally; selects and pins the exact project snapshot without fallback; requires typed builtin.project_membership success before authoritative context or ordinary project hooks; never calls external services in-path; strips/redacts headers per §6; audits candidate, establishment, snapshot, bootstrap, and final decision separately.
Fixtures (GAT-248): at least one real integrity-issued credential (DataIntegrityProof eddsa-rdfc-2022) with its true sourceCid; contract vectors for each form A–D × each reason code; credential-list canonicalization vectors; a signed-request vector with the §9 covered-component order.