/*
RFC-1213: verifier-side verifiable-credential contracts — the verdict shape
the agent gate binds to ActionContext after checking a W3C VC DM 2.0
verifiable presentation, the journaled record schema, and the site-level
trusted-issuer registry schema. The contract is PII-free at schema level:
PresentationVerdict carries verdict facts only (matched requirement, opaque
subject binding, issuer, failure reason) — no field can hold a presented
claim value, and the journal record hashes the presentation, never stores it.
- Do not parse or verify presentations — the verifier boundary lives in
werkstatt-engine/src/agent-gate/presentation.ts.
- Do not store claim values anywhere — persistence of presented claims
is forbidden at schema level (RFC-1213).
- Do not model credential issuance or holder UX — verifier side only.
- RFC-1213: initial contracts — VerifiablePresentationRequirement accept-entry schema, PresentationVerdict, journal record, trusted-issuer registry.
*/
import { z } from "zod";
// ---------------------------------------------------------------------------
// Requirement declaration (consumed by capability `requires.verifiablePresentation`)
// ---------------------------------------------------------------------------
/**
* One acceptable credential shape inside a presentation requirement. `type` is
* the closed VC type identifier the credential must carry;
* `issuers` mixes inline issuer ids (`did:`/`https:`) with `@name` references
* into the site's `trusted-issuers.yaml`. An absent/empty `issuers` means
* "any issuer" — explicit, never implicit (AGP-05 gates `@name` resolution).
*/
export const presentationAcceptEntrySchema = z
.object({
/** Closed VC type identifier, e.g. "AgeOver18Credential". */
type: z.string().min(1),
/** Claim paths required on the credential subject, e.g. "credentialSubject.ageOver". */
claims: z.array(z.string().min(1)).optional(),
/** Inline issuer ids or "@name" refs into trusted-issuers.yaml; absent = any issuer. */
issuers: z.array(z.string().min(1)).optional(),
})
.strict();
export type PresentationAcceptEntry = z.infer;
/**
* RFC-1213: the closed `requires.verifiablePresentation` block. `bindTo` is
* v1-fixed to "verified-agent" — a presentation must cryptographically bind
* to the caller's verified agent identity; anonymous callers cannot satisfy
* the requirement (replay protection, AUDIT-RFC-1213-01).
*/
export const verifiablePresentationRequirementSchema = z
.object({
accept: z.array(presentationAcceptEntrySchema).min(1),
/** Human-readable reason shown to the wallet/agent. */
purpose: z.string().min(1),
/** v1 fixed: the VP holder/subject binding MUST equal the verified agentId. */
bindTo: z.literal("verified-agent"),
})
.strict();
export type VerifiablePresentationRequirement = z.infer<
typeof verifiablePresentationRequirementSchema
>;
// ---------------------------------------------------------------------------
// Verdict — the ONLY shape presentation data may take downstream (AC-6)
// ---------------------------------------------------------------------------
/**
* The verification verdict bound to ActionContext. No field can carry a
* presented claim value — `subjectBinding` is the opaque verified agentId the
* presentation bound to, `issuer` the credential issuer id. Claim leakage
* requires changing this schema, which is what RFC-1213 exists to gate.
*/
export const presentationVerdictSchema = z
.object({
verified: z.boolean(),
/** Which accept[] entry matched — the VC type identifier. */
matchedRequirement: z.string().min(1).optional(),
/** The verified agentId the presentation bound to — opaque correlation handle. */
subjectBinding: z.string().min(1).optional(),
issuer: z.string().min(1).optional(),
failureReason: z.string().min(1).optional(),
})
.strict();
export type PresentationVerdict = z.infer;
// ---------------------------------------------------------------------------
// Journal record — hashes and verdicts only
// ---------------------------------------------------------------------------
/** RFC-1213: schema marker stamped into every journaled presentation verdict. */
export const PRESENTATION_VERDICT_SCHEMA_ID = "gogol.presentation-verdict/1";
/**
* The journaled verification record — `{requirementId, verified, issuer,
* failureReason, presentationHash}` per the RFC. `presentationHash` is a
* sha256 of the canonical presentation, never the presentation itself.
*/
export const presentationJournalRecordSchema = z
.object({
schema: z.literal(PRESENTATION_VERDICT_SCHEMA_ID),
siteId: z.string().min(1),
capability: z.string().min(1),
requirementId: z.string().min(1),
verified: z.boolean(),
issuer: z.string().min(1).optional(),
failureReason: z.string().min(1).optional(),
presentationHash: z.string().min(1),
occurredAt: z.string().min(1),
})
.strict();
export type PresentationJournalRecord = z.infer;
// ---------------------------------------------------------------------------
// Trusted-issuer registry — src/content/agent/trusted-issuers.yaml
// ---------------------------------------------------------------------------
/**
* Site-level issuer trust registry: named issuer-id sets referenced by
* `accept[].issuers` `@name` entries so issuer policy does not drift across
* capabilities. File: `src/content/agent/trusted-issuers.yaml`.
*/
export const trustedIssuerRegistrySchema = z
.object({
issuers: z.record(z.string().min(1), z.array(z.string().min(1)).min(1)).default({}),
})
.strict();
export type TrustedIssuerRegistry = z.infer;
/**
* Resolve one `accept[].issuers` list against the registry: `@name` entries
* expand to the named set, inline ids pass through. Returns the resolved set
* plus any unresolved `@name` references (AGP-05 surfaces those).
*/
export function resolveIssuerRefs(
issuers: readonly string[] | undefined,
registry: TrustedIssuerRegistry,
): { resolved: string[]; unresolvedRefs: string[] } {
const resolved: string[] = [];
const unresolvedRefs: string[] = [];
for (const ref of issuers ?? []) {
if (ref.startsWith("@")) {
const named = registry.issuers[ref.slice(1)];
if (named) resolved.push(...named);
else unresolvedRefs.push(ref);
} else {
resolved.push(ref);
}
}
return { resolved, unresolvedRefs };
}