← Back to Home

Zero-Knowledge Compliance (ZKC) Protocol Specification

User-controlled compliance for privacy-preserving systems

Version 0.4.1-draft | Status: Draft | License: Apache 2.0 | Domain: ZKC.dev

Date: 2026-09-06 | Proving System: Noir / UltraHonk (BN254) — proving-system-agnostic at protocol level

Abstract

Zero-Knowledge Compliance (ZKC) is a protocol for user-controlled compliance in privacy-preserving systems. ZKC enables users to generate cryptographic proofs satisfying regulatory requirements without revealing underlying private data. Compliance becomes a capability controlled by the user, not an enforcement mechanism controlled by the protocol.

ZKC is designed to integrate with privacy-preserving payment systems (including ZKA), authorization layers (including ZKM), and settlement layers (Namada, Penumbra, Ethereum L2s) while remaining agnostic to specific implementations.


1. Overview

1.1 Design Philosophy

Compliance as Capability, Not Constraint

Traditional compliance models require protocols to enforce rules—blocking non-compliant transactions, freezing assets, or enabling force transfers. ZKC inverts this model:

Traditional Model ZKC Model
Protocol enforces compliance User generates compliance proofs
Counterparty demands access User chooses what to disclose
Compliance is mandatory Compliance is optional capability
Protocol knows identity Protocol is identity-blind
Regulators access protocol Regulators verify user proofs

Core Principles:

  1. User Sovereignty: Users control when, what, and to whom they disclose
  2. Protocol Neutrality: No compliance logic in the base protocol
  3. Verifier Flexibility: Verifiers decide what proofs they accept
  4. Portability: Works across multiple chains and payment systems
  5. Privacy Preservation: Proofs reveal minimum necessary information

1.2 Protocol Components

┌─────────────────────────────────────────────────────────────────┐
│                     ZKC PROTOCOL STACK                          │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    PROOF TYPES                            │  │
│  │  KYC | Jurisdiction | Accreditation | Tax | Source | ...  │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              │                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                 ATTESTATION LAYER                         │  │
│  │  Credential Format | Issuance | Revocation | Registry     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              │                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    CLIENT SDK                             │  │
│  │  Proof Generation | Key Management | Credential Store     │  │
│  └──────────────────────────────────────────────────────────┘  │
│                              │                                  │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                   VERIFIER SPEC                           │  │
│  │  Proof Formats | Verification | Trust Policies            │  │
│  └──────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

1.2a Relationship to ZKM (Informative)

Within the ZK protocol family, ZKC establishes eligibility, ZKM establishes principal-issued spend authority, and ZKA establishes whether the value transition settles. ZKC does not issue, consume, delegate, or revoke ZKM mandates, and ZKM mandate revocation does not alter ZKC credential validity.

A future ZKM policy MAY consume a ZKC proof through an explicitly versioned composition profile. ProofContextProfile (§2.2) is already extensible enough to bind such a proof to a ZKM interaction, but context binding alone does not establish that the credential holder is the mandate principal or the ZKA recipient. The composing ZKM/ZKA profile is responsible for that same-subject proof. No such profile is defined by this specification.

1.3 Notation

Symbol Meaning
H(domain, x...) Poseidon2 hash over BN254 with domain tag, per AFP-KDC v1.0.2 §A.3 (AFP Specification, Appendix A)
commit(v, r) Poseidon2 commitment to value v with randomness r
π Zero-knowledge proof (proving-system-agnostic; primary implementation: Noir/UltraHonk)
cred Verifiable credential
isk Issuer signing key
ivk Issuer verification key
cbk Credential binding key
acc Cryptographic accumulator
proofContext Deployment/profile-defined replay context bound into a ZKC proof

The zkc/* artifact identifiers and operational domain tags are owned by ZKC; the zkm/* namespace is owned by ZKM. A future ZKC revision that adds a domain tag MUST collision-check it against the KDC registry and the published operational tags of sibling protocols, including ZKA and ZKM. Namespace adjacency does not make a ZKM proof, receipt, delegation, or revocation artifact a ZKC artifact.


1.4 Freedom Safeguards (Normative)

ZKC operates beneath the Freedom Safeguards defined normatively in ZKA Specification §1.6. These are architectural commitments that distinguish privacy infrastructure from a compliance control mechanism. Safeguards 3 (No Revocability) and 5 (Agent Exit Rights) are core invariants of the settlement layer; ZKC's design MUST NOT introduce any mechanism that undermines them. In particular, ZKC credential revocation MUST affect only a counterparty's acceptance of a proof — never the validity, custody, or transferability of any underlying note or asset (see §2.4, §4.4).

Safeguards 1, 2, and 4 are integration-level invariants that bind ZKC credential and predicate artifacts directly; Safeguard 6 — Verifier Accountability (added in v0.3.0) is a new axis that binds the counterparty rather than the protocol or operator. A conforming ZKC implementation MUST satisfy all four:

Safeguard 1 — Predicate Pluralism. No credential type, proof type, predicate, or issuer is privileged, canonical, or required by this protocol. ZKC MUST NOT define any mechanism that designates an "official" or "default" credential or predicate set. The identifiers in Appendix A and the schemas in §3.1–3.7 are well-known reference definitions for interoperability, not a closed or mandated set: a verifier's trust policy (§6) MAY accept credential types, predicates, or issuers outside this specification, and is never required to accept any particular one. Credential and predicate providers are independent and permissionless.

Safeguard 2 — Minimal Disclosure by Default. ZKC proofs MUST be atomic — each proof attests a single predicate (e.g., "not sanctioned"). Composite disclosure MUST require explicit, separate, principal-readable consent per predicate (see §5, §6.1). SDK defaults MUST generate the minimal proof satisfying a request; broader disclosure MUST require explicit additional configuration.

Safeguard 4 — Open-Source Predicate Verification. All ZKC credential and predicate circuits MUST be open-source and publicly auditable, expressed in inspectable source (Noir or equivalent). Predicate and credential-schema updates MUST be versioned, published, and subject to a public review period before activation. A conforming ZKC SDK MUST, by default, refuse to generate a proof against a predicate or schema the holder cannot inspect; overriding this default MUST require explicit, logged opt-in.

Safeguard 6 — Verifier Accountability. A verifier that requests a disclosure MUST authenticate the request under a stable verifier identity (an Authenticated Proof Request, §5.5), and the holder MUST be able to retain a self-contained Disclosure Receipt (§5.6) of exactly what was demanded, for what declared purpose (§2.2), and what was answered. A conforming ZKC SDK MUST refuse by default to answer an unauthenticated request (mirroring the Safeguard 4 fail-closed posture; overriding the default MUST require explicit, logged opt-in), MUST bind declared purpose into the proof context under the purpose-bound profile, and MUST NOT require any operator, prover, proxy, indexer, hosted SDK, or compliance service to custody receipts. This is the first safeguard to constrain the counterparty rather than the protocol or operator; operator-blindness and verifier-accountability are orthogonal (a blind operator and an unaccountable verifier are independent failures), so it is a distinct invariant, not an extension of Safeguard 2. It governs the verifier's request, and distinguishes request accountability — which is reachable, because a holder has the transaction leverage to demand a signed request (no signature ⇒ no proof) — from decision accountability, which is structurally unreachable: a verifier may decline in silence, and the protocol cannot force a signed decision (§5.8, residual limit E.1).

Audit check (Safeguard 6). A conforming deployment is checkable: the SDK refuses unauthenticated requests by default; purpose is a bound public input under the purpose-bound profile (zkc:context:purpose-bound:v1); and receipts are holder-custodied with operator retention absent. These are exercised as executable ZKC-Core conformance cases (safeguard-6-*, §9.2) alongside the Safeguard 1 and Safeguard 4 checks.

A conforming ZKC deployment MUST NOT require an issuer, verifier, predicate provider, prover service, compliance service, indexer, proxy, or hosted SDK operator to receive masterSeed, spending keys, ZKA addresses, note plaintext, unencrypted ZKC witness material, payment history, or custody of Disclosure Receipts. Remote services MAY assist with issuance, proof generation, verification, witness updates, or audit export only when they receive public inputs, encrypted witness material, viewing-limited material, or holder-controlled delegated authority that is insufficient for unilateral spending or linkable identity reconstruction.

Where this specification and ZKA §1.6 differ in wording, the safeguard intent as stated in ZKA §1.6 governs.


1.5 ZKM Relationship and Protocol-Family Boundaries (Informative)

ZKC, Zero-Knowledge Mandates (ZKM), and ZKA occupy distinct layers of a composable protocol family. Their artifacts may be bound together by an integration profile, but conformance to one layer does not imply conformance to either of the others:

Layer Responsibility Does not establish
ZKC eligibility Proves holder-controlled facts and predicates, such as credential validity, jurisdiction, or accreditation. A mandate, permission to act, transaction approval, or spending authority.
ZKM authorization Proves that a principal authorized an agent to perform a bounded action under a mandate. Eligibility for a regulated action or successful settlement.
ZKA settlement Defines shielded assets, transactions, custody, and settlement validity. The holder's eligibility or an agent's mandate.

A composed flow may therefore require all three independently: a ZKC proof that the relevant party is eligible, a ZKM proof that the action is authorized, and ZKA validation that the resulting transaction settles. The presence or validity of one artifact is not evidence for a different layer unless an explicit integration profile binds and verifies the required artifacts.

Domain-namespace ownership. ZKC owns domain-separation tags under zkc/*; ZKM owns tags under zkm/*. Each owning specification defines the preimages and semantics of its namespace. ZKC extensions do not assign semantics under zkm/*, and ZKM extensions do not redefine or mint new semantics under zkc/*. A cross-protocol construction composes separately namespaced values or uses an extension profile defined by the namespace owner; it does not transfer namespace ownership.

Proof-context extension. ProofContextProfile (§2.2) is the ZKC extension point for a future ZKM-defined context profile. Such a profile can bind a ZKC eligibility proof to a ZKM authorization context by mapping the ZKM-defined context into ZKC's circuit-facing Hash. ZKM remains responsible for the profile's zkm/* identifier, derivation, and mandate semantics; ZKC transports and verifies only the resulting context value according to that profile. Defining such a profile does not by itself change ZKC credentials, circuits, proof envelopes, presentation profiles, or conformance levels.

Delegation terminology. ZKC delegated proof generation (§5.3) delegates a narrowly scoped computation, and delegateViewing (§5.4) delegates access to viewing-limited material for that computation. Neither operation creates or proves a ZKM mandate, authorizes an action on a principal's behalf, or conveys custody, transaction-signing, withdrawal, or spending authority. An integration that needs mandate or spend authorization obtains and verifies that authority separately under ZKM or the applicable settlement protocol.


2. Cryptographic Primitives

2.1 Credential Binding

ZKC credentials bind to shielded identities (e.g., ZKA addresses) without revealing the identity. This enables proving "I hold credential X" without revealing "I am address Y."

interface CredentialBinding {
  // User's credential binding key (derived from master seed)
  credentialBindingKey: CredentialBindingKey;

  // Public commitment to binding key (used in credentials)
  bindingCommitment: Hash;  // H("zkc/binding", cbk)

  // Proof that credential applies to a shielded identity
  // without revealing which identity
  bindingProof: ZKProof;
}

// Credential-binding-key derivation is defined by AFP-KDC v1.0.2 §A.6
// (AFP Specification, Appendix A). ZKC does not restate it.
//
//   cbk        = H("zkc/credential", masterSeed)   // KDC §A.6
//   commitment = H("zkc/binding",    cbk)          // KDC §A.6
//
// `masterSeed` is a 256-bit seed: a standalone master seed, or — under
// AFP entity identity (AFP §4.3) — a per-entity sub-seed M' (KDC §A.5).
// `cbk` derives from the seed, not from the spending key; the credential
// commitment and the ZKA address derive along separate paths and are
// unlinkable without the seed (KDC §A.6, derivation facts 1 and 2).
//
// A conforming ZKC implementation MUST derive cbk and the binding
// commitment per AFP-KDC v1.0.2.

Canonical master-seed profile. AFP-KDC v1.0.2 preserves ZKC's frozen afp-kdc:master-seed:v1.0.1 compatibility identifier and its byte-for-byte behavior. A seed is exactly 32 secret bytes. For the circuit-facing KDC input, interpret those bytes as one unsigned big-endian integer and reduce it modulo the BN254 scalar-field modulus r; a Noir Field parameter is this already-mapped value, not a second serialization of the seed. Host APIs MUST preserve the original 32 bytes for every byte-domain derivation and MUST NOT reconstruct them from the reduced field element. SDK byte-array inputs are normative. A compatibility integer input is encoded as unsigned, big-endian, left-zero-padded to 32 bytes and MUST be in [0, 2^256); JSON profiles encode a seed as exactly 0x plus 64 lowercase hexadecimal digits. Booleans, floats, negative or overflowing integers, variable-width bytes, noncanonical hex, and implicit string-to-integer coercions MUST be rejected.

The modulo mapping intentionally admits KDC aliases: for example, seed r maps to the same KDC field input as seed zero. AFP-KDC v1.0.2 retains this explicitly versioned v1.0.1 behavior for byte-for-byte compatibility with deployed identities. It applies only at the KDC circuit boundary. Receipt, audit-authorization, delegation, and any other byte-domain derivation consume the original 32 seed bytes, so they MUST NOT collapse such aliases. The frozen boundary and asymmetric cases are published in tests/vectors/kdc_master_seed_v1.json; the complete current KDC conformance package is published in tests/vectors/crypto/afp_kdc_v1_0_2_conformance.json.

New seeds MUST be generated as 32 bytes directly from a cryptographically secure random source. Implementations MUST NOT generate a host integer and truncate it, reduce generated bytes modulo r, or serialize only the KDC field input; doing so either loses entropy or prevents exact recovery of the seed. Migration is representation-only: every existing valid 32-byte seed retains its AFP-KDC v1.0.1 identity and its byte-identical AFP-KDC v1.0.2 outputs and byte-domain keys. Legacy stores holding an exact unsigned 256-bit integer may convert it once to the canonical 32-byte big-endian encoding. A store that retained only the reduced field input cannot recover the seed and MUST NOT silently treat that field element as the original seed; it requires an explicitly versioned legacy identity or a user-authorized rotation/rebind.

Cross-reference. bindingCommitment = H("zkc/binding", cbk) is derived per AFP-KDC v1.0.2 §A.6 (AFP Appendix A). It is the shared anchor on which ZKA's identity-binding circuit is layered (ZKA Specification §5.2). That circuit is a ZKA-side addition and is not specified here; ZKC remains authoritative for credential-internal mechanics (structure, issuance, revocation, accumulators), and KDC is authoritative for the derivation of cbk and the binding commitment.

2.2 Proof Context Binding

Every ZKC proof is bound to a replay context. The context is a deployment/profile-defined hash that identifies the session, counterparty, and purpose for which the proof is valid. Implementations MAY expose structured fields for user readability, but the proof-circuit-facing value is always a single Hash.

type ProofContextProfile =
  | "zkc:context:verifier-session:v1"
  | "zkc:context:purpose-bound:v1"     // verifier accountability (§5.5–§5.7)
  | "afp:session_context:v1"            // legacy; not an AFP 0.3.11-or-later proof context
  | string;                             // extension profiles, including future ZKM profiles

interface ProofContext {
  // Names the context construction profile.
  profile: ProofContextProfile;

  // Circuit-facing replay context.
  // For "zkc:context:verifier-session:v1":
  //   value = H("zkc/context", profile, verifierId, sessionId, timestamp)
  // For "zkc:context:purpose-bound:v1":
  //   purposeTag = H("zkc/purpose", canonicalPurpose)
  //   value      = H("zkc/context", profile, verifierId, sessionId, purposeTag, timestamp)
  // For legacy "afp:session_context:v1" only:
  //   value = afp:envelope:v1.session_context
  value: Hash;

  // Principal-readable fields, when the profile defines them.
  verifierId?: VerifierIdentifier;
  sessionId?: Hash;
  timestamp: Timestamp;
  purpose?: string;
  purposeTag?: Hash;  // present under the purpose-bound profile
}

ProofContextProfile is the extension point for a future ZKM context profile; it is not a grant of mandate or spending authority. A ZKM profile MUST be defined and namespaced by ZKM under zkm/*, MUST specify how its structured authorization context maps to the circuit-facing Hash, and MUST be explicitly requested and verified by both sides. ZKC does not define that future profile or infer ZKM authorization from an unrecognized profile string.

claimBinding MUST commit to the context value. A verifier MUST reject a proof when proof.metadata.context.value does not equal the requested context value, or when profile-specific structured fields do not reconstruct the same value.

Purpose-Bound Profile (zkc:context:purpose-bound:v1)

Under the base verifier-session profile, purpose is a principal-readable field that is not committed into the circuit-facing context value, so a disclosure obtained for one stated purpose (e.g. billing KYC) is not cryptographically distinct from one for another (e.g. an access gate) and cross-purpose re-derivation goes uncaught. The purpose-bound profile closes this by folding the declared purpose into the same context value via a purposeTag:

purposeTag = H("zkc/purpose", canonicalPurpose)      // canonicalPurpose: NFC-normalized, registry-tagged
value      = H("zkc/context", profile, verifierId, sessionId, purposeTag, timestamp)

This profile is OPTIONAL to offer, but once a verifier requests it, binding is mandatory. Deployments handling reusable plaintext credentials (passports, KYC dossiers) SHOULD default to it (§5.7). Existing verifier-session contexts are unchanged. afp:session_context:v1 is a legacy pre-AFP 0.3.11 profile and MUST NOT be selected as the ZKC proof context for an AFP 0.3.11-or-later tuple; those tuples select afp:zkc-presentation-context:v2 instead.

When a ZKC proof is carried in a ZKA zka:bundle:v1, the ZKA bundle remains authoritative for the bundle envelope. In a payment bundle, the ZKA-side note-to-credential binding links the payment note to the ZKC holder commitment. In a transaction-absent ZKA v0.9.2-draft work-attestation bundle, there is no note and therefore no note-to-credential binding. AFP retains the authenticated raw X_... session identifier in afp:envelope:v1.session_context; ZKA bundle metadata, transaction public inputs, and Coord receipts use only its fixed nonzero BN254 session_context_field projection. A ZKC proof instead uses AFP's separately authenticated afp:zkc-presentation-context:v2 presentation-purpose context. A verifier MUST NOT substitute either session representation for that ZKC proof context. ZKC validators verify the ZKC proof and its context; ZKA validators enforce bundle substantiveness, coord_proof verification, and EMPTY_BUNDLE rejection.

The machine-checked composition boundary is epoch zka:protocol-family:2026-09-10.1 in ZKA's canonical protocol-family/ package and migration note. It binds ZKA v0.9.2-draft, ZKC v0.4.1-draft, AFP v0.3.12-draft, AFP-KDC v1.0.3 current registry metadata with its immutable v1.0.2 crypto profile, and ZKM v0.1.0-draft (rev 12) with statement-set identity sha256:e4604c92377ab121ab8834c1243935b77c32969d129564574f54e72ae55ecab7 and its beta.25/BB 5.0.0 proving toolchain. AFP 0.3.12 publishes its experimental venue-neutral payment descriptor and separately versioned v2 policy, quote, and receipt records; it does not change any ZKC circuit, verification key, proof envelope, public-input ABI, or presentation-context mode. ZKC vendors a commit-locked snapshot and rejects unknown, unmatched, ambiguous, or incomplete family combinations without changing frozen v1 bytes.

2.3 Credential Structure

interface ZKCCredential {
  // === Credential Header ===
  header: {
    version: "zkc:cred:v1";
    type: CredentialType;
    issuer: IssuerIdentifier;
    issuedAt: Timestamp;
    expiresAt: Timestamp | null;
  };

  // === Credential Body ===
  body: {
    // Commitment to holder's binding key (not the key itself)
    holderCommitment: Hash;

    // Type-specific claims (structure varies by credential type)
    claims: CredentialClaims;

    // Credential unique identifier (for revocation)
    credentialId: Hash;
  };

  // === Issuer Signature ===
  signature: {
    scheme: "ECDSA_SECP256K1" | "BLS" | "Schnorr";
    value: Uint8Array;
    issuerKey: IssuerVerificationKey;
  };

  // === Revocation Info ===
  revocation: {
    // Accumulator membership witness (for non-revocation proofs)
    accumulatorWitness: AccumulatorWitness;
    accumulatorEpoch: u64;
  };
}

The initial Noir conformance profile defines ECDSA_SECP256K1 as the mandatory issuer-signature circuit profile. BLS and Schnorr remain protocol-recognized credential metadata schemes, but a verifier MUST reject them unless it has an auditable circuit implementation for that scheme.

2.4 Accumulator-Based Revocation

Credentials use cryptographic accumulators for privacy-preserving revocation checks:

interface RevocationAccumulator {
  // Current accumulator value
  value: G1Affine;

  // Epoch (increments on each update)
  epoch: u64;

  // Update: remove credential from accumulator (revoke)
  revoke(credentialId: Hash): AccumulatorUpdate;

  // Prove membership (credential not revoked)
  proveMembership(
    credentialId: Hash,
    witness: AccumulatorWitness
  ): NonRevocationProof;
}

// Non-revocation proof
interface NonRevocationProof {
  // Proves credentialId is still in accumulator
  // without revealing which credential
  proof: ZKProof;

  // Public inputs
  publicInputs: {
    accumulatorValue: G1Affine;
    accumulatorEpoch: u64;
    // Note: credentialId is NOT revealed
  };
}

The initial Noir conformance profile uses a Poseidon2 Merkle accumulator over active credentialId leaves. An issuer publishes the accumulator root and epoch; holders receive Merkle path witness updates out-of-band or through privacy preserving broadcast channels. A verifier accepts an accumulator epoch only when currentEpoch - accumulatorEpoch <= MAX_EPOCH_LAG and MUST reject future epochs. Revocation changes only future proof acceptance by counterparties; it never changes ZKA note validity, custody, or transferability.

Issuer metadata for accumulator publication, update endpoints, epoch freshness, and revocation service levels is specified in docs/zkc_issuer_adapter_protocol_v1.md and schemas/issuer-metadata.schema.json.

2.4.1 Authenticated revocation publication (launch profile)

For the issuer-authoritative selective-disclosure profile in §7.2b, an unsigned currentRoot/currentEpoch pair is discovery data only. A verifier MUST accept a root only from a trust-anchored zkc:epoch-announcement:v3 carried by zkc:issuer-metadata:v2. Metadata v1 remains parseable for legacy consumers but MUST NOT authenticate a §7.2b root; silently treating it as v2 is a downgrade failure.

The v3 announcement is the following closed object; additional or missing fields make it malformed:

version:                  "zkc:epoch-announcement:v3"
accumulatorId:            UTF-8 text (1..65535 bytes)
root:                     canonical 32-byte lower-case 0x hex field element
epoch:                    u64
announcedAt:              positive Unix seconds as u64
maxEpochLag:              u64 in [0, 10]
signingKeyId:             UTF-8 text (1..65535 bytes)
previousAnnouncementHash: 32-byte lower-case 0x hex
signature:                64-byte lower-case 0x ECDSA secp256k1 r||s

The exact bytes signed are independent of JSON serialization. Define lp16(s) = u16be(len(UTF8(s))) || UTF8(s). The signing transcript is:

ASCII("ZKC-EPOCH-ANNOUNCEMENT") || 0x00 || 0x03 ||
lp16(version) || lp16(accumulatorId) || root[32] ||
u64be(epoch) || u64be(announcedAt) || u64be(maxEpochLag) ||
lp16(signingKeyId) || previousAnnouncementHash[32]

The signer computes d = OS2IP(SHA-256(transcript)) mod p, where p is the BN254 scalar-field modulus, encodes d as a 32-byte big-endian field element, and signs those bytes with deterministic low-s ECDSA secp256k1. This transcript binds the protocol version, accumulator identity, root, epoch, freshness time, lag policy, key identifier, and predecessor commitment. Because the object is closed, an issuer cannot introduce an unsigned security-relevant announcement field.

The predecessor commitment covers the complete canonical signed predecessor, not merely its root. Let T be that predecessor's signing transcript and S its 64-byte signature. Its canonical complete bytes are:

ASCII("ZKC-EPOCH-ANNOUNCEMENT-COMPLETE") || 0x00 || 0x03 ||
u16be(len(T)) || T || S

previousAnnouncementHash is SHA-256(completeBytes), with 32 zero bytes at genesis. tests/vectors/revocation_announcement_v3.json is the normative known-answer vector for both encodings.

Metadata v2 MUST publish the current complete announcement atomically inside revocation.currentAnnouncement. The convenience fields accumulatorId, currentRoot, currentEpoch, and maxEpochLag MUST exactly equal the signed announcement values. A mismatch fails closed. The current announcement MUST already be durable and retrievable, and every intervening historical announcement MUST remain retrievable in epoch order for bounded gap backfill, before metadata exposes the new head.

A native v3 accumulator begins at epoch 0 with the zero predecessor. A deployment migrating an already populated legacy accumulator MAY publish one v3 migration checkpoint at its existing epoch with the zero predecessor, but only to a verifier that has no retained v3 checkpoint and whose local policy explicitly authorizes that accumulator migration. It is accepted as a local checkpoint, not as proof of legacy-chain continuity or a globally unique view. Once any v3 checkpoint is retained, another zero-predecessor announcement or an attempt to splice a v2 announcement into the v3 predecessor hash fails closed. Reusing v1 metadata or v2 announcement bytes as though they were v3 is never migration.

signingKeyId selects a key already authorized by verifier-local trust policy. accumulatorId likewise MUST match the accumulator identity pinned for that issuer; a different id is a separately authorized migration, not a cold-start checkpoint. This prevents restart from erasing continuity merely by changing the signed accumulator namespace. The same metadata document's signingKeys entry is discovery material and MUST NOT bootstrap its own authority. Launch rotation is pre-authorized: verifiers pin the successor key out of band before accepting the first announcement it signs. An implementation MAY accept a separately specified delegation authenticated by an already pinned key, but metadata v2 defines no self-authenticating delegation object. An unknown key id, an unpinned same-document key, an unauthorized rotation, or a return to metadata v1 fails closed.

Epoch semantics are frozen for this launch version. Genesis is epoch 0. Each successful credential registration/issuance advances exactly one epoch and publishes one announcement; each successful revocation does the same; an atomic renewal/replace advances exactly one epoch for the combined add/remove. Validation failures, duplicate/no-op requests, and rejected mutations do not advance the epoch or publish. Batching, minimum epoch periods, and padding or cover updates were evaluated but are not part of v3; the resulting observable issuance/revocation cadence is an explicit accepted launch leakage described in docs/zkc_metadata_leakage_threat_model.md. Changing this rule requires a new announcement version.


3. Credential Types

The credential schemas in §3.1–3.7 are reference schemas: well-known interoperability profiles, not a closed or mandated set. Per §1.4 (Safeguard 1 — Predicate Pluralism), no credential type or issuer defined here is privileged or required, and verifiers MAY accept credential types and predicates outside this specification. These schemas exist so independent issuers and verifiers can interoperate without coordination, not to designate an official taxonomy.

3.1 KYC Credential

Proves identity verification by an accredited provider.

interface KYCCredential extends ZKCCredential {
  header: {
    type: "zkc:kyc:v1";
    // ...
  };

  body: {
    holderCommitment: Hash;
    credentialId: Hash;

    claims: {
      // Verification level (tiered KYC)
      level: "basic" | "enhanced" | "institutional";

      // What was verified (not the actual data)
      verifiedAttributes: {
        name: boolean;
        dateOfBirth: boolean;
        address: boolean;
        governmentId: boolean;
        livenessCheck: boolean;
      };

      // Jurisdiction of verification
      jurisdiction: ISO3166Alpha2;

      // Sanctions screening performed
      sanctionsScreened: boolean;
      sanctionsScreenDate: Timestamp;
    };
  };
}

// Proof: "I hold valid KYC from a trusted issuer"
interface KYCProof {
  proofType: "zkc:proof:kyc:v1";

  // What is proven
  claim: {
    // Minimum verification level
    minLevel: "basic" | "enhanced" | "institutional";

    // Required verified attributes
    requiredAttributes: string[];

    // Issuer trust requirement
    issuerRequirement: IssuerRequirement;
  };

  // The proof
  proof: ZKProof;

  // Public inputs
  publicInputs: {
    // Commitment to holder (for binding to transaction)
    holderCommitment: Hash;

    // Claim binding (prevents proof reuse for different claims)
    claimBinding: Hash;

    // Non-revocation anchor
    accumulatorEpoch: u64;

    // Freshness (prevents replay)
    timestamp: Timestamp;
  };
}

3.2 Jurisdiction Credential

Proves residency or citizenship without revealing identity.

interface JurisdictionCredential extends ZKCCredential {
  header: {
    type: "zkc:jurisdiction:v1";
    // ...
  };

  body: {
    holderCommitment: Hash;
    credentialId: Hash;

    claims: {
      // Type of jurisdictional claim
      claimType: "residency" | "citizenship" | "tax_residency";

      // Jurisdiction (hidden in proofs, proven via set membership)
      jurisdiction: ISO3166Alpha2;

      // Verification method
      verificationMethod: "government_id" | "utility_bill" | "tax_return" | "passport";

      // Validity period
      validFrom: Timestamp;
      validUntil: Timestamp;
    };
  };
}

// Proof: "I am resident of an allowed jurisdiction"
interface JurisdictionProof {
  proofType: "zkc:proof:jurisdiction:v1";

  claim: {
    // Allowed jurisdictions (verifier specifies)
    allowedJurisdictions: ISO3166Alpha2[];

    // Or: blocked jurisdictions
    blockedJurisdictions?: ISO3166Alpha2[];

    // Claim type required
    claimType: "residency" | "citizenship" | "tax_residency";
  };

  proof: ZKProof;

  publicInputs: {
    holderCommitment: Hash;
    claimBinding: Hash;

    // Merkle root of allowed/blocked jurisdiction set
    jurisdictionSetRoot: Hash;

    // Proves: my jurisdiction ∈ allowedSet OR my jurisdiction ∉ blockedSet
    membershipType: "inclusion" | "exclusion";

    accumulatorEpoch: u64;
    timestamp: Timestamp;
  };
}

3.3 Accreditation Credential

Proves accredited investor status or institutional classification.

interface AccreditationCredential extends ZKCCredential {
  header: {
    type: "zkc:accreditation:v1";
    // ...
  };

  body: {
    holderCommitment: Hash;
    credentialId: Hash;

    claims: {
      // Accreditation type
      accreditationType:
        | "accredited_investor"      // US SEC definition
        | "qualified_purchaser"      // US higher threshold
        | "professional_investor"    // EU MiFID II
        | "eligible_counterparty"    // EU highest tier
        | "institutional";           // Generic institutional

      // Jurisdiction of accreditation
      jurisdiction: ISO3166Alpha2;

      // Basis for accreditation (not the actual values)
      basis: {
        incomeTest: boolean;
        netWorthTest: boolean;
        professionalCertification: boolean;
        entityType: boolean;
      };
    };
  };
}

// Proof: "I meet accreditation requirements"
interface AccreditationProof {
  proofType: "zkc:proof:accreditation:v7";

  claim: {
    // Required accreditation level
    requiredType: AccreditationType[];

    // Accepted jurisdictions
    acceptedJurisdictions: ISO3166Alpha2[];
  };

  proof: ZKProof;

  publicInputs: {
    holder_commitment: Hash;
    claim_binding: Hash;
    proof_context: Hash;
    predicate_type: u8;
    set_root: Hash;              // 0 for accreditation
    accumulator_root: Hash;
    accumulator_epoch: u64;
    timestamp: u64;
    current_epoch: u64;
    issuer_public_key_x: Bytes32;
    issuer_public_key_y: Bytes32;
  };
}

3.4 Source of Funds Credential

Proves funds originated from verified sources.

interface SourceCredential extends ZKCCredential {
  header: {
    type: "zkc:source:v1";
    // ...
  };

  body: {
    holderCommitment: Hash;
    credentialId: Hash;

    claims: {
      // Source category
      sourceType:
        | "employment_income"
        | "business_income"
        | "investment_returns"
        | "inheritance"
        | "gift"
        | "sale_of_assets"
        | "regulated_exchange"
        | "other_verified";

      // Verification details
      verification: {
        method: "bank_statement" | "tax_return" | "exchange_records" | "legal_documents";
        verifierType: "bank" | "accountant" | "exchange" | "legal";
      };

      // Amount range (not exact amount)
      amountRange: {
        currency: ISO4217;
        min: u64;
        max: u64;
      };

      // Time period
      period: {
        from: Timestamp;
        to: Timestamp;
      };
    };
  };
}

// Proof: "My funds come from verified sources"
interface SourceProof {
  proofType: "zkc:proof:source:v7";

  claim: {
    // Acceptable source types
    acceptableSources: SourceType[];

    // Minimum verification standard
    minVerificationLevel: "self_declared" | "document_verified" | "third_party_verified";
  };

  proof: ZKProof;

  publicInputs: {
    holder_commitment: Hash;
    claim_binding: Hash;
    proof_context: Hash;
    predicate_type: u8;
    set_root: Hash;              // Accepted source-code set
    accumulator_root: Hash;
    accumulator_epoch: u64;
    timestamp: u64;
    current_epoch: u64;
    issuer_public_key_x: Bytes32;
    issuer_public_key_y: Bytes32;
  };
}

3.5 Tax Compliance Credential

Proves tax obligations have been met.

interface TaxCredential extends ZKCCredential {
  header: {
    type: "zkc:tax:v1";
    // ...
  };

  body: {
    holderCommitment: Hash;
    credentialId: Hash;

    claims: {
      // Tax jurisdiction
      jurisdiction: ISO3166Alpha2;

      // Tax year
      taxYear: u16;

      // Compliance status
      status: "filed" | "filed_and_paid" | "no_liability";

      // What was reported (not actual values)
      reportedCategories: {
        capitalGains: boolean;
        income: boolean;
        foreignAssets: boolean;
      };

      // Issuer type
      issuerType: "tax_authority" | "licensed_accountant" | "tax_software";
    };
  };
}

// Proof: "I have met tax obligations for jurisdiction X"
interface TaxProof {
  proofType: "zkc:proof:tax:v7";

  claim: {
    // Required jurisdiction
    jurisdiction: ISO3166Alpha2;

    // Required tax years
    taxYears: u16[];

    // Minimum status
    minStatus: "filed" | "filed_and_paid";
  };

  proof: ZKProof;

  publicInputs: {
    holder_commitment: Hash;
    claim_binding: Hash;
    proof_context: Hash;
    predicate_type: u8;
    set_root: Hash;              // Accepted tax-jurisdiction set
    accumulator_root: Hash;
    accumulator_epoch: u64;
    timestamp: u64;
    current_epoch: u64;
    issuer_public_key_x: Bytes32;
    issuer_public_key_y: Bytes32;
  };
}

3.6 Sanctions Clearance Credential

Proves absence from sanctions lists (negative proof).

interface SanctionsClearanceCredential extends ZKCCredential {
  header: {
    type: "zkc:sanctions:v1";
    // ...
  };

  body: {
    holderCommitment: Hash;
    credentialId: Hash;

    claims: {
      // Lists checked
      listsChecked: {
        OFAC: boolean;
        UN: boolean;
        EU: boolean;
        UK: boolean;
        other: string[];
      };

      // Screening date
      screeningDate: Timestamp;

      // Result
      result: "clear" | "potential_match_resolved";

      // Screening provider
      providerType: "regulated_entity" | "compliance_service";
    };
  };
}

// Proof: "I am not on sanctions lists"
interface SanctionsClearanceProof {
  proofType: "zkc:proof:sanctions:v7";

  claim: {
    // Required lists
    requiredLists: ("OFAC" | "UN" | "EU" | "UK")[];

    // Maximum age of screening
    maxScreeningAge: Duration;
  };

  proof: ZKProof;

  publicInputs: {
    holder_commitment: Hash;
    claim_binding: Hash;
    proof_context: Hash;
    predicate_type: u8;
    set_root: Hash;              // Blocked screening-code set
    accumulator_root: Hash;
    accumulator_epoch: u64;
    timestamp: u64;
    current_epoch: u64;
    issuer_public_key_x: Bytes32;
    issuer_public_key_y: Bytes32;
  };
}

3.7 Issuer-Authoritative Attribute-Set Credential

zkc:cred:attribute-set:v1 is the reference credential for revealing selected raw field attributes while proving that every revealed value and position came from issuer-authoritative state. It contains an issuer-signed active attributeCount (1–8) and eight fixed attribute slots; slots at or above the count MUST be zero.

Its proof entry is zkc:proof:selective_disclosure:v1, carried only in a zkc:proof:v2 anonymous or scoped presentation envelope. The public statement contains credential_attribute_count, disclosure_count, disclosed_flags[8], and disclosed_attributes[8]. Array index is the attribute position. A hidden slot is (false, 0) and a disclosed zero is (true, 0); implementations MUST NOT infer disclosure from whether the value is zero.

The complete issuer digest, typed transcript, public-input ABI, and consumer boundary are normative in docs/zkc_selective_disclosure.md. ZKA and AFP MAY carry the resulting versioned ZKC envelope but MUST delegate its cryptographic and issuer-state verification to ZKC.


4. Attestation Layer

4.1 Issuer Registration

interface Issuer {
  // Unique identifier
  id: IssuerIdentifier;  // H(name, jurisdiction, verificationKey)

  // Public information
  metadata: {
    name: string;
    jurisdiction: ISO3166Alpha2;
    website: URL;
    credentialTypes: CredentialType[];

    // Regulatory status
    regulatoryStatus: {
      regulated: boolean;
      regulator?: string;
      licenseNumber?: string;
    };

    // Optional issuer-assisted credential rebinding support.
    // SECURITY: "zkc:proof:credential_rebind:v1" is a historical, contained
    // identity (§4.5): it authenticates no credential and MUST NOT be
    // advertised or accepted for live migration.
    credentialRebinding?: {
      supported: boolean;
      modes: ("zkc:proof:credential_rebind:v1" | "issuer_assisted")[];
      auditMapping?: {
        sealedAtRest: boolean;
        accessControlled: boolean;
        purpose: "migration_audit_compliance";
        retention: "minimum_applicable_period";
        publicSurfacesForbidden: (
          | "settlement"
          | "indexer"
          | "note_discovery"
          | "compliance_bundle"
          | "public_proof"
          | "public_log"
        )[];
      };
    };
  };

  // Cryptographic keys
  keys: {
    // Current signing key
    currentKey: IssuerVerificationKey;

    // Key rotation history
    keyHistory: {
      key: IssuerVerificationKey;
      validFrom: Timestamp;
      validUntil: Timestamp;
      revoked: boolean;
    }[];
  };

  // Revocation accumulator
  accumulator: {
    current: G1Affine;
    epoch: u64;
    updateEndpoint: URL;
  };
}

4.2 Issuer Registry

The issuer registry is decentralized and verifier-controlled:

interface IssuerRegistry {
  // No central authority
  // Each verifier maintains their own trust list

  // Registry provides discovery, not trust
  discover(query: IssuerQuery): Issuer[];

  // Verifiers publish their trust policies
  publishTrustPolicy(policy: TrustPolicy): void;

  // Users can find verifiers that accept their credentials
  findAcceptingVerifiers(credential: ZKCCredential): Verifier[];
}

interface TrustPolicy {
  // Verifier identifier
  verifier: VerifierIdentifier;

  // Trusted issuers by credential type
  trustedIssuers: {
    credentialType: CredentialType;
    issuers: IssuerIdentifier[];

    // Or: minimum requirements for any issuer
    requirements?: {
      regulated: boolean;
      jurisdictions: ISO3166Alpha2[];
      minReputation?: u64;
    };
  }[];

  // Policy metadata
  metadata: {
    name: string;
    description: string;
    lastUpdated: Timestamp;
  };
}

4.2a Registry Governance (Reference Registry)

This repository ships a reference, file-based registry (registry/) realizing §4.2 and the Safeguard 1/4 obligations for predicates: versioned predicate entries with hash-pinned circuit identity, review status, and public review evidence (registry/predicates/), issuer metadata documents (registry/issuers/), and published verifier trust policies (registry/verifiers/). Governance process, immutability/evolution/deprecation rules, and review-status semantics are specified in docs/zkc_predicate_registry_governance.md.

Normative constraints (restating §1.4 for any ZKC registry, including this one):

Conformance: an implementation that treats registry membership as trust fails ZKC-Core (seeded negative safeguard-1-registry-membership-not-trust).

4.3 Credential Issuance Flow

CREDENTIAL ISSUANCE
═══════════════════

┌──────────┐         ┌──────────┐         ┌──────────┐
│   User   │         │  Issuer  │         │ Registry │
└────┬─────┘         └────┬─────┘         └────┬─────┘
     │                    │                    │
     │  1. Request        │                    │
     │  credential        │                    │
     │  (with binding     │                    │
     │   commitment)      │                    │
     │───────────────────>│                    │
     │                    │                    │
     │                    │  2. Verify         │
     │                    │  identity          │
     │                    │  (off-chain KYC)   │
     │                    │                    │
     │  3. Issue          │                    │
     │  credential        │                    │
     │  (signed, with     │                    │
     │   accumulator      │                    │
     │   witness)         │                    │
     │<───────────────────│                    │
     │                    │                    │
     │                    │  4. Update         │
     │                    │  accumulator       │
     │                    │  (add credential)  │
     │                    │───────────────────>│
     │                    │                    │
     │  5. Store          │                    │
     │  credential        │                    │
     │  locally           │                    │
     │                    │                    │

4.4 Revocation Flow

CREDENTIAL REVOCATION
═════════════════════

┌──────────┐         ┌──────────┐         ┌──────────┐
│  Issuer  │         │ Registry │         │ Verifier │
└────┬─────┘         └────┬─────┘         └────┬─────┘
     │                    │                    │
     │  1. Revoke         │                    │
     │  credential        │                    │
     │  (remove from      │                    │
     │   accumulator)     │                    │
     │───────────────────>│                    │
     │                    │                    │
     │                    │  2. Publish        │
     │                    │  new accumulator   │
     │                    │  epoch             │
     │                    │───────────────────>│
     │                    │                    │
     │                    │                    │  3. Verifiers
     │                    │                    │  require proofs
     │                    │                    │  against new
     │                    │                    │  epoch
     │                    │                    │

4.5 Credential Rebinding and Issuer Audit Mappings

Credential rebinding migrates a credential from an old holder commitment to a new holder commitment after key rotation or recovery, without revealing seeds or publishing an old-to-new link.

Security containment (2026-09-06) — the published rebind statements are unauthenticated. The four published rebind identities, zkc:proof:credential_rebind:v1, zkc:proof:rebind:v2, zkc:proof:credential_rebind:v2, and zkc:proof:rebind:v3, constrain only that the prover knows the credential binding keys behind the old and new holder handles. Every credential field they carry (holder commitment, credential identifier, issuer and schema hashes, both validity bounds) is an unconstrained private witness chosen by the prover, and no issuer signature and no accumulator membership are verified. A cryptographically valid proof under any of them is therefore evidence of attacker-chosen key continuity only; it is not evidence of credential ownership, issuance, or live (non-revoked) continuity, and it MUST NOT be described or accepted as such.

Normative consequences: (1) a holder MUST NOT select, generate, or negotiate any of the four identities for live credential migration, and MUST fail an explicit request with a stable security error; (2) a verifier MUST reject any envelope or request naming one of them with UNAUTHENTICATED_CREDENTIAL before backend verification, at every entry point, with an empty and with a configured issuer trust policy alike — an Authenticated Proof Request establishes requester accountability, never credential issuance; (3) request-pinned issuerIdHash / schemaIdHash values are equality filters over prover-chosen values and are never issuer authentication; (4) the historical circuit sources, encoders, binding sidecars, artifacts, and registry pins stay byte-frozen as historical records and are never reinterpreted; (5) the registry entries [email protected]–4.0.0 are deprecated with no historical-verification exception, because the statements never authenticated the fact they were used to assert. The supported mechanism is the pair of additive issuer-authenticated successors specified in §7.4 — zkc:proof:credential_rebind:v3 (linkable) and zkc:proof:rebind:v4 (scoped) — which verify the issuer's signature over the zkc:attestation:credential-rebind:v1 transcript recomputed in-circuit from constrained fields and prove fresh non-revocation for the same credential identifier. A request MUST name a successor explicitly; implementations MUST NOT map a deprecated identity to a successor or a successor to a deprecated identity (no downgrade path).

Issuer-assisted migration MAY be offered as an integration optimization, but it MUST NOT replace the core rebind proof. If an issuer retains any old-holder-commitment to new-holder-commitment mapping, the mapping MUST be sealed and minimized:

  1. Access-controlled and encrypted or equivalently sealed at rest.
  2. Purpose-limited to migration audit/compliance.
  3. Retained for the minimum applicable period.
  4. Excluded from settlement, indexer, note-discovery, compliance-bundle, public-proof, public-log, analytics, and metadata surfaces.
  5. Unusable to affect ZKA note validity, custody, transferability, withdrawal, or exit rights.

Conformance tests for issuer-assisted rebinding MUST include negative cases asserting that retained mappings are not emitted in issuer metadata, credential payloads, proof public inputs, compliance bundles, note announcements, public logs, indexer records, or revocation feeds.


5. Client SDK

5.1 Core Interface

interface ZKCClient {
  // === Initialization ===

  // Initialize from the canonical 32-byte AFP-KDC v1.0.2 seed
  static fromSeed(seed: Uint8Array): ZKCClient;

  // Initialize from existing ZKA key hierarchy
  static fromZKAKeys(keys: ZKAKeyHierarchy): ZKCClient;

  // === Credential Management ===

  // Store credential
  storeCredential(credential: ZKCCredential): void;

  // List stored credentials
  listCredentials(filter?: CredentialFilter): ZKCCredential[];

  // Update accumulator witness (for non-revocation proofs)
  updateWitness(credentialId: Hash): Promise<AccumulatorWitness>;

  // === Proof Generation ===

  // Generate compliance proof
  generateProof(
    request: ProofRequest,
    credentials: ZKCCredential[]
  ): Promise<ComplianceProof>;

  // Generate compliance proof with a remote prover that cannot inspect
  // witness plaintext or obtain spending authority.
  generateDelegatedProof(
    request: DelegatedProofRequest,
    credentials: EncryptedCredentialWitness[]
  ): Promise<ComplianceProof>;

  // === Binding ===

  // Get binding commitment (for credential requests)
  getBindingCommitment(): Hash;

  // Prove credential binds to shielded identity
  proveBinding(
    credential: ZKCCredential,
    targetIdentity: IdentityProof
  ): Promise<BindingProof>;
}

5.2 Proof Generation

interface ProofRequest {
  // What the verifier requires
  requirements: {
    // Required proof types
    proofTypes: ProofType[];

    // Type-specific parameters
    parameters: ProofParameters;

    // Freshness requirement
    maxProofAge: Duration;

    // Context binding (prevents replay across sessions/profiles)
    context: ProofContext;
  };

  // Verifier's trust policy (which issuers accepted)
  trustPolicy: TrustPolicy;

  // OPTIONAL signed presentation policy (§5.9). Absence requests anonymous.
  presentation?: {
    profile: "zkc:presentation:anonymous:v1" |
             "zkc:presentation:scoped:v1" |
             "zkc:presentation:linkable:v1";
    scopePolicy?: string;
    rotationEpochSeconds?: u64;       // 0 = no rotation
  };
}

interface DelegatedProofRequest {
  proofRequest: ProofRequest;

  // Remote prover identity and accepted predicate source hashes.
  prover: ProverIdentifier;
  acceptedPredicateSources: PredicateSourceHash[];

  // Encrypted witness payloads; plaintext credentials, cbk, masterSeed,
  // spending keys, ZKA addresses, note plaintext, and payment history are
  // forbidden in delegated requests.
  encryptedWitnesses: EncryptedWitnessTransport[];

  // Holder-controlled authorization. This may allow one proof generation for
  // the stated context, but MUST NOT authorize spending, withdrawal, credential
  // export, or generation for a different context.
  delegation: DelegationToken;
}

// Generate proof
async function generateComplianceProof(
  client: ZKCClient,
  request: ProofRequest
): Promise<ComplianceProof> {
  // 1. Find matching credentials
  const credentials = client.listCredentials({
    types: request.requirements.proofTypes,
    issuers: request.trustPolicy.trustedIssuers,
    notExpired: true
  });

  if (credentials.length === 0) {
    throw new Error("No matching credentials");
  }

  // 2. Update accumulator witnesses
  for (const cred of credentials) {
    await client.updateWitness(cred.body.credentialId);
  }

  // 3. Generate proofs
  const proofs: IndividualProof[] = [];

  for (const proofType of request.requirements.proofTypes) {
    const cred = credentials.find(c => c.header.type === proofType);

    const proof = await generateIndividualProof(
      proofType,
      cred,
      request.requirements.parameters[proofType],
      request.requirements.context
    );

    proofs.push(proof);
  }

  // 4. Aggregate into single proof (if multiple)
  const aggregatedProof = aggregateProofs(proofs);

  // 5. Bind to context
  const boundProof = bindToContext(aggregatedProof, request.requirements.context);

  return boundProof;
}

5.3 Delegated Proof Generation

Agents MAY delegate proof generation to a remote prover or compliance service, but baseline ZKC conformance is non-custodial. A delegated prover MUST NOT receive masterSeed, spending keys, ZKA addresses, note plaintext, unencrypted credential witnesses, raw credential-binding keys, or payment history. The holder SDK MUST fail closed when a service requests those materials.

Delegated proof generation authorizes only production of the scoped ZKC proof. It MUST NOT be interpreted as a ZKM mandate, permission to act for the holder, transaction approval, a reusable holder capability, or delegation of custody, signing, withdrawal, or spending authority.

A delegation token and every field it carries MUST be treated as untrusted input. Before generating or releasing holder-controlled proof material, the holder SDK MUST resolve the expected delegation verification key from authenticated local holder state and verify the token under that locally resolved key. It MUST NOT use holderKeyX, holderKeyY, or any other token-carried key as the authorization trust anchor. In the reference 32-byte affine-coordinate profile, the SDK MUST reject non-exact coordinate encodings, compare both coordinates with constant-time byte comparisons against the locally derived key, and reject a malformed key, coordinate mismatch, or invalid signature before proof or material generation.

Scope, expiry, nonce, consent, credential, predicate-registry, issuer-policy, accumulator, and proving-backend checks are additive controls. An implementation MUST NOT treat any of them as a substitute for the cryptographic holder-key anchor above. Token-carried holder-key coordinates MAY remain in a wire representation for compatibility and token self-consistency checks, but they do not authenticate the holder or widen the delegated authority.

The holder commitment alone also MUST NOT bootstrap delegation authority. Under the frozen AFP-KDC mapping, distinct exact seed-byte strings can be KDC aliases with the same commitment; byte-domain delegation-key derivation intentionally keeps those seeds distinct. A token signed by an alias seed therefore fails the locally resolved delegation-key check even when its claimed holder commitment is equal.

A conforming delegated proving flow has these properties:

  1. The holder constructs a ProofContext and reviews the principal-readable predicate request before delegation.
  2. The holder encrypts witness material to a prover mechanism that can produce the requested proof without exposing witness plaintext to the service operator, or uses an equivalent holder-controlled execution environment.
  3. The delegated authorization is scoped to one proof request, one context value, one predicate source set, and one expiry.
  4. The prover returns a zkc:proof:v1 object whose public inputs include the requested proofContext.
  5. The holder or verifier rejects the result if the context, predicate source hash, issuer trust policy, accumulator epoch, or proof type differs from the delegated request.

Hosted custody profiles, managed wallets, and compliance outsourcing products MAY exist as deployment/product profiles, but they MUST NOT be represented as baseline ZKC conformance unless the host lacks unilateral spend authority and cannot reconstruct holder identity linkage beyond the explicitly delegated scope.

5.4 Agent API

// Agent-native interface for programmatic compliance
interface ZKCAgentClient extends ZKCClient {
  // === Automated Compliance ===

  // Configure auto-response to proof requests
  setCompliancePolicy(policy: AgentCompliancePolicy): void;

  // Handle incoming proof request automatically
  handleProofRequest(request: ProofRequest): Promise<ComplianceProof | Rejection>;

  // === Principal Viewing Delegation (not spend authority) ===

  // Delegate viewing-limited access to principal.
  // This conveys no ZKM mandate or spending authority.
  // Minting derives the delegation key and holder commitment from the same
  // authenticated local masterSeed. Token-carried holder-key coordinates are
  // compatibility metadata, never the authorization trust anchor.
  delegateViewing(
    principal: PublicKey,
    scope: ViewingScope
  ): DelegationToken;

  // === Batch Operations ===

  // Pre-generate proofs for anticipated requests
  precomputeProofs(anticipatedRequests: ProofRequest[]): void;

  // Verify counterparty compliance before transacting
  verifyCounterparty(
    counterpartyProof: ComplianceProof,
    requirements: ProofRequest
  ): VerificationResult;
}

interface AgentCompliancePolicy {
  // Auto-respond to these proof types
  autoRespond: {
    proofType: ProofType;
    maxDisclosure: DisclosureLevel;
    trustedVerifiers: VerifierIdentifier[];
  }[];

  // Require approval for these
  requireApproval: ProofType[];

  // Never respond to these
  block: ProofType[];

  // Principal to notify on compliance events
  notifyPrincipal: PublicKey;
}

delegateViewing delegates only the stated viewing scope. It MUST NOT be represented or accepted as a ZKM mandate, a delegation of ZKA spending authority, or authority to generate a proof outside that viewing scope.

Delegation minting contract. A conforming implementation MUST canonicalize masterSeed once and derive both the delegation signing key and holderCommitment from those same authenticated local seed bytes. If a lower-level minting API accepts a caller-supplied holderCommitment for compatibility, it MUST accept only the exact integer representation, recompute the commitment from the canonical seed bytes, reject any mismatch before signing, and serialize and sign the recomputed value. The public-key coordinates emitted in the token identify the derived key for wire compatibility; consumers MUST still apply the local-holder key invariant in §5.3 rather than trusting those coordinates. This contract neither exports the seed nor changes the token wire format or the authority delegated by delegateViewing.

5.5 Authenticated Proof Request

A verifier that wishes to receive a proof MUST present an Authenticated Proof Request (APR): its ProofRequest (§5.2), signed under a verifier identity key. The APR wraps the request and adds no holder-identifying data. The optional presentation policy is part of that signed request.

interface AuthenticatedProofRequest {
  request: ProofRequest;               // §5.2, including optional presentation policy
  verifierId: VerifierIdentifier;      // SHOULD anchor to an AFP/KERI AID (§5.8, E.2)
  nonce: Hash;                         // anti-replay; unique per request
  issuedAt: Timestamp;
  expiresAt: Timestamp;

  // Signature over canonical serialization of the complete request plus:
  //   H("zkc/apr", verifierId, request, nonce, issuedAt, expiresAt)
  signature: {
    scheme: "ECDSA_SECP256K1" | "Ed25519";
    value: Uint8Array;
    verifierKey: VerifierVerificationKey;
  };
}

Normative SDK behavior.

When named-entity evidentiary value is required, verifierId SHOULD anchor to an AFP/KERI AID so the signing key has the identity continuity a counterparty or court recognizes (§5.8, E.2).

5.6 Disclosure Receipt

On answering — or explicitly refusing — an APR, the SDK MUST retain a Disclosure Receipt: holder-custodied, self-contained, third-party-verifiable evidence of exactly what was demanded and what was answered.

interface DisclosureReceipt {
  apr: AuthenticatedProofRequest;      // the verifier-signed demand, verbatim
  outcome: "answered" | "refused";
  // Present iff answered. Binds THIS holder's pseudonymous commitment to the
  // request, so the receipt is self-contained portable evidence.
  response?: {
    holderCommitment: Hash;            // pseudonymous but stable/linkable (§8.2)
    contextValue: Hash;                // == apr.request.requirements.context.value
    answeredProofTypes: ProofType[];
  };
  holderSignature: {
    scheme: "ECDSA_SECP256K1" | "Ed25519";
    value: Uint8Array;                 // over H("zkc/receipt", apr, outcome, response)
  };
  recordedAt: Timestamp;
}

Custody and service boundary (normative).

5.7 Plaintext-Disclosure Scope (Normative)

A purpose binding that covers only ZK proofs leaves the plaintext channel as a laundering path: a verifier obtains a real passport "for billing" and re-evaluates nationality outside ZKC entirely.

Therefore a conforming deployment that accepts any holder attribute in plaintext, or in a form the verifier can re-evaluate (KYC dossiers, document scans, revealed claims), MUST gate that disclosure behind an APR (§5.5) carrying a purpose-bound context (§2.2), and MUST retain a receipt (§5.6) on the same terms as a ZK proof. A purpose declared for a plaintext disclosure is a binding representation: re-evaluating that attribute under a different purpose without a fresh APR is a provable purpose violation, evidenced by the holder's receipt set. ZKC cannot prevent the re-evaluation; it makes the violation attributable.

5.8 Residual Accountability Limits (Normative)

These limits are normative disclosure. They MUST NOT be omitted by downstream documentation or softened into optional notes.

E.1 — Decision accountability is unreachable. Receipts attest the request and the holder's response, never the verifier's decision. A verifier may decline silently. Against a single-predicate request a decline is nearly dispositive; against a compound predicate, which-conjunct-failed remains ambiguous and cannot be forced into the open. This is a leverage asymmetry, not an implementation gap: a holder has the leverage to demand a signed request (no signature ⇒ no proof) but none to demand a signed decision.

E.2 — Evidentiary value is parasitic on verifier-identity continuity. A receipt proves "key K asked me X." It proves "Verifier V asked me X" only if K is bound to V with continuity a court or counterparty recognizes. Throwaway per-request keys degrade the receipt to an anonymous record. Real verifiers (exchanges, banks, model providers) cannot transact under throwaway identity and remain the entity you are transacting with; adversarial ones can. Anchoring verifierId to an AFP/KERI AID is therefore REQUIRED for the receipt to carry named-entity weight. The cryptography is neutral; the real-world binding is not in scope of this mechanism.

E.3 — Cost falls on the verifier, not the holder. The APR binds the verifier to its demand while the holder discloses nothing new (the verifier already observed the proof). The price paid is verifier deniability, not holder privacy. A benign verifier asking lawful predicates incurs no meaningful cost; a coercive one resists signing, and that refusal is the signal.

5.9 Presentation Profiles (Normative)

The base v1 presentation exposes a stable, cross-verifier-linkable holderCommitment in every proof (§8.2). The presentation-profile ladder corrects this: a presentation is one predicate family plus one presentation profile that determines whether — and how narrowly — the holder is re-identifiable across presentations. Three profiles are defined; this design implements amended ADR 0001 (docs/adr/0001-multi-show-unlinkable-presentations.md).

Presentation profile Public handle Selection
zkc:presentation:anonymous:v1 none (pseudonym and scope both 0) default — used whenever no presentation block is requested
zkc:presentation:scoped:v1 presentationPseudonym + scopeId requested via an authenticated APR (§5.5)
zkc:presentation:linkable:v1 global holderCommitment explicit consent; the legacy v1 envelope/circuit surfaces, unchanged

zkc:presentation:anonymous:v1 is the default profile: anonymity is the ambient default, and continuity is always an explicit, signed, purpose-bound request — never ambient. The escrowed auditor-linkable profile is out of scope (separate ADR).

Implementation model. The anonymous and scoped profiles are served by the v2 circuit family and the zkc:proof:v2 envelope (§6.1). Those circuits move holderCommitment to a private witness and add two public inputs, presentationPseudonym and scopeId, governed by a single sentinel rule: scopeId == 0 means anonymous (the circuit constrains presentationPseudonym == 0, so the handle carries no holder information); scopeId != 0 means scoped (the circuit constrains the pseudonym to derive from the credential binding key and the scope). The linkable profile is not reimplemented — it is the existing, byte-for-byte-frozen v1 circuit/envelope family, grandfathered and consent-gated. A verifier that forces scopeId = 0 merely downgrades itself to anonymous; the failure direction is more privacy, never less.

5.9.1 Derivations

All new domain constants follow the existing derive_domain pattern (§1.3, KDC §A.3) and are pinned by assertion plus golden vector:

DOMAIN_ZKC_PRESENTATION_SCOPE = derive_domain("zkc/presentation-scope:v1")
DOMAIN_ZKC_SCOPE              = derive_domain("zkc/scope:v1")
DOMAIN_ZKC_VERIFIER_ID        = derive_domain("zkc/verifier-id:v1")

The presentation pseudonym is the only derivation that must be provable in-circuit, so it uses Poseidon:

presentationPseudonym = H("zkc/presentation-scope:v1", cbk, scopeId)

The verifier identity and scope id are computed natively by the holder and the verifier — never proven in-circuit — over the APR signer's SEC1 compressed public key:

verifierIdentity = H("zkc/verifier-id:v1", sec1_compressed(aprVerifierPubkey))
scopeId          = H("zkc/scope:v1", verifierIdentity, scopePolicy, rotationEpoch)

5.9.2 APR presentation block

The APR request object gains an OPTIONAL presentation block, covered by the existing APR signature (apr_digest_from_dict extends to include it):

"presentation": {
  "profile": "zkc:presentation:scoped:v1",
  "scopePolicy": "verifier",          // or "protocol:<name>"
  "rotationEpochSeconds": 0            // 0 = no rotation
}

An absent block ⇒ zkc:presentation:anonymous:v1. Holder-side rules:

  1. Scoped requests require an authenticated APR (Safeguard 6, §5.5). An unauthenticated request cannot establish verifierIdentity, so it can only be answered anonymously (or refused).
  2. The holder derives scopeId itself from the APR signer key. A verifier-supplied literal scopeId anywhere in the request is a protocol error — the holder NEVER accepts a verifier-supplied opaque scope.
  3. A scopePolicy starting with "protocol:" requires a distinct consent flag (allow_protocol_scope) on the holder's ProofRequest.
  4. The holder MAY downgrade scoped → anonymous; it MUST NOT silently upgrade anonymous → scoped or scoped → linkable.

5.9.3 Per-profile receipts

The Disclosure Receipt response (§5.6) binds the active profile's handle, never more:

Profile response contents
anonymous contextValue, answeredProofTypes — no holder-derived handle
scoped + presentationPseudonym, scopeId
linkable the legacy v1 shape (holderCommitment, …) — unchanged

The global holderCommitment MUST NOT enter an anonymous or scoped (v2) receipt.

5.9.4 Verifier rules

For a zkc:proof:v2 envelope, the verifier (§6.2) applies, in order:

  1. Policy gate. presentationBinding.profile MUST be in TrustPolicy.accepted_profiles (default {anonymous, scoped}) else PROFILE_NOT_ACCEPTED. This gate applies to v2 envelopes only; v1 (linkable) envelopes are never gated at verify time (the linkable-consent requirement lives in the holder/APR emission layer).
  2. Shape consistency (anti-replay). For every proof entry: anonymous ⇒ presentationPseudonym == 0 && scopeId == 0; scoped ⇒ scopeId != 0, presentationPseudonym == presentationBinding.pseudonym, and scopeId == presentationBinding.scopeId. Any mismatch ⇒ PROFILE_MISMATCH. This replaces the v1 envelope-vs-proof holderCommitment consistency check.
  3. Scope recomputation (scoped + authenticated path). The verifier recomputes scopeId from its own signing identity, the APR scopePolicy, and the rotation epoch; a mismatch ⇒ SCOPE_MISMATCH. A verifier cannot accept a scoped proof for a scope not derived from its own authenticated identity unless its policy explicitly declares the protocol scope.
  4. Rebind / aggregation. Rebind and aggregation v2 require scopeId != 0 (there is no anonymous continuity claim); the verifier reads presentationPseudonym rather than holderCommitment and enforces old/new-pseudonym and envelope-scope consistency. The only rebind statement admissible under the scoped profile is the issuer-authenticated zkc:proof:rebind:v4 (§7.4); the deprecated zkc:proof:rebind:v2 / zkc:proof:rebind:v3 identities are rejected (§4.5).

PROFILE_MISMATCH, SCOPE_MISMATCH, and PROFILE_NOT_ACCEPTED join the existing structured error family (§6.2). A zkc:proof:v1 envelope takes the existing v1 verification path unchanged and is classified zkc:presentation:linkable:v1.

5.9.5 Public-input determinism invariant

The property that makes the ladder hold is stated normatively, not left incidental:

No public input of a presentation may be deterministic in credential-only material unless it is the declared linking handle of the active profile.

This is a MUST: under the anonymous profile presentationPseudonym and scopeId are the constant 0; under the scoped profile the only credential-deterministic handle is presentationPseudonym, which is scope-exclusive (it changes with scopeId). In particular claimBinding satisfies the invariant only because its preimage retains proofContext and timestamp, so it rotates per presentation — that inclusion is load-bearing and MUST be preserved in every v2 circuit. Conformance tests MUST enforce the invariant for every public input a profile adds (§9.2).


6. Verifier Specification

6.1 Proof Format

interface ComplianceProof {
  // Protocol version
  version: "zkc:proof:v1";

  // Proof metadata
  metadata: {
    generatedAt: Timestamp;
    expiresAt: Timestamp;

    // Context binding
    context: ProofContext;
  };

  // Individual proofs (may be aggregated)
  proofs: {
    proofType: ProofType;
    proof: ZKProof;
    publicInputs: PublicInputs;
  }[];

  // Holder binding (links all proofs to same identity)
  holderBinding: {
    commitment: Hash;
    bindingProof: ZKProof;
  };

  // Aggregation proof (if multiple proofs)
  aggregation?: {
    proof: ZKProof;
    inputProofCommitments: Hash[];
  };
}

Envelope v2 (zkc:proof:v2). Anonymous and scoped presentations (§5.9) use the zkc:proof:v2 envelope (schemas/compliance-proof-v2.schema.json). It replaces holderBinding with a presentationBinding block and never carries holderBinding:

interface ComplianceProofV2 {
  version: "zkc:proof:v2";
  metadata: { generatedAt; expiresAt; context: ProofContext };
  proofs: { proofType; proof; publicInputs }[];   // v2 proof types, §4/§5.9
  presentationBinding: {
    profile: "zkc:presentation:anonymous:v1" | "zkc:presentation:scoped:v1";
    // present iff profile == scoped, and equal to every proof's public inputs:
    pseudonym?: Hash;   // == presentationPseudonym
    scopeId?: Hash;     // == scopeId
  };
  aggregation?: { proof: ZKProof; inputProofCommitments: Hash[] };
  // NOTE: no `holderBinding` — that field exists only in v1 (linkable) envelopes.
}

zkc:proof:v1 envelopes (with holderBinding.commitment) remain valid and are classified zkc:presentation:linkable:v1; the envelope-level presentationBinding and the per-proof public inputs are cross-checked (§5.9.4) so profile ⇔ public-input shape consistency prevents cross-profile replay. The Disclosure Receipt response (§5.6) is per-profile (§5.9.3): anonymous binds only contextValue + answeredProofTypes; scoped adds presentationPseudonym

Within every proof entry, the canonical wire key for each encoder-declared public input is exactly the snake_case name entry from the encoder document for the circuit selected by proofType (schemas/public-inputs/<circuit>.json), and the input MUST be serialized under that key verbatim. A camelName published by a public-input binding sidecar MUST NOT be used as a proof-entry wire key: request parameters and the v1 and v2 aggregation blocks use camelCase at their own layers.

6.2 Verification Algorithm

interface ZKCVerifier {
  // Trust policy
  trustPolicy: TrustPolicy;

  // Accumulator cache
  accumulatorCache: Map<IssuerIdentifier, AccumulatorState>;

  // Verify compliance proof
  verify(proof: ComplianceProof, request: ProofRequest): VerificationResult;
}

/**
 * Normative step-4e dispatcher. Implementations MAY organize these checks
 * differently, but every accepted individual proof MUST have one name-keyed
 * contract and MUST produce the same fail-closed outcomes.
 */
function verifyTypeSpecificRequirements(
  individualProof: IndividualProof,
  request: ProofRequest
): VerificationError[] {
  const contract = requestBindingContract(individualProof.proofType);
  if (contract === undefined || contract.kind !== "individual") {
    return [{
      code: "UNBOUND_PROOF_TYPE",
      message: `No request-binding contract for ${individualProof.proofType}`
    }];
  }

  const parameters = request.requirements.parameters[individualProof.proofType];
  if (!isPlainObject(parameters)) {
    return [{
      code: "REQUIREMENTS_NOT_MET",
      message: `Missing or unusable parameters for ${individualProof.proofType}`
    }];
  }

  const errors: VerificationError[] = [];
  for (const entry of contract.typeSpecificInputs) {
    try {
      // Public inputs are resolved by their snake_case names, never by field
      // offset. In particular, remaining-predicates v1/v2 reverse the order of
      // predicate_type and set_root.
      const actual = readPublicInputByName(individualProof, entry.name);
      if (entry.class === "bound") {
        const expected = resolveBoundValue(entry.source, parameters, request);
        if (!typeStrictEqual(actual, expected)) {
          errors.push(requirementsNotMet(entry.name, individualProof.proofType));
        }
      } else if (entry.class === "derived") {
        const expected = recomputeDerivedValue(entry, parameters, request, individualProof);
        if (!typeStrictEqual(actual, expected)) {
          errors.push(requirementsNotMet(entry.name, individualProof.proofType));
        }
      }
      // A free input has no request-side expected value. Its binding document
      // MUST contain the written soundness justification required by §6.2.1;
      // acceptance MUST NOT be represented as enforcing that value.
    } catch (error) {
      errors.push({
        code: error instanceof PublicInputDecodeError
          ? "MALFORMED_PROOF"
          : "REQUIREMENTS_NOT_MET",
        message: error.message
      });
    }
  }
  errors.push(...contract.familyChecks(individualProof, parameters, request));
  return errors;
}

async function verifyComplianceProof(
  verifier: ZKCVerifier,
  proof: ComplianceProof | ComplianceProofV2,
  request: ProofRequest,
  apr: AuthenticatedProofRequest   // §5.5: the verifier's own signed demand
): Promise<VerificationResult> {
  const errors: VerificationError[] = [];

  // 0. Authenticated request (Safeguard 6, §5.5). The verifier echoes its own
  //    APR; the holder/relay checks it. SDK-side, generation is refused
  //    without it. This step is ADDITIVE — steps 1+ are unchanged, and an
  //    existing proof/bundle verifies exactly as before.
  if (!verifyAPRSignature(apr) || aprExpired(apr) || nonceSeen(apr.nonce)) {
    errors.push({ code: "UNSIGNED_REQUEST", message: "Request not authenticated" });
  }
  if (canonicalSerialize(request) !== canonicalSerialize(apr.request)) {
    errors.push({ code: "UNSIGNED_REQUEST", message: "Request differs from signed APR" });
    return { valid: false, errors };
  }
  if (apr.request.requirements.context.profile === "zkc:context:purpose-bound:v1" &&
      proof.metadata.context.value !== recomputePurposeBoundContext(apr)) {
    errors.push({ code: "PURPOSE_MISMATCH", message: "Bound purpose does not match request" });
  }

  // 1. Check proof freshness
  if (proof.metadata.generatedAt < request.requirements.context.timestamp - request.requirements.maxProofAge) {
    errors.push({ code: "STALE_PROOF", message: "Proof too old" });
  }

  // 2. Check context binding
  if (proof.metadata.context.value !== request.requirements.context.value ||
      proof.metadata.context.profile !== request.requirements.context.profile) {
    errors.push({ code: "CONTEXT_MISMATCH", message: "Proof bound to different context" });
  }

  // 3. Verify the envelope-version-specific binding. Selective-disclosure
  //    presentations use v2 and therefore never enter the holderBinding path.
  if (proof.version === "zkc:proof:v1") {
    if (!verifyHolderBinding(proof.holderBinding)) {
      errors.push({ code: "INVALID_BINDING", message: "Holder binding invalid" });
    }
  } else if (proof.version === "zkc:proof:v2") {
    const bindingErrors = verifyPresentationBinding(
      proof.presentationBinding,
      proof.proofs,
      apr.request.presentation
    );
    errors.push(...bindingErrors); // PROFILE_* / SCOPE_* errors from §5.9.4
  } else {
    errors.push({ code: "MALFORMED_PROOF", message: "Unknown proof envelope version" });
    return { valid: false, errors };
  }

  // 4. Verify each individual proof
  for (const individualProof of proof.proofs) {
    // 4a. Check proof type is required
    if (!request.requirements.proofTypes.includes(individualProof.proofType)) {
      continue; // Extra proof, ignore
    }

    // 4b. Verify the ZK proof
    const vk = getVerificationKey(individualProof.proofType);
    if (!zkVerify(vk, individualProof.publicInputs, individualProof.proof)) {
      errors.push({
        code: "INVALID_PROOF",
        message: `Invalid proof for ${individualProof.proofType}`
      });
      continue;
    }

    if (individualProof.publicInputs.proof_context !== proof.metadata.context.value) {
      errors.push({
        code: "CONTEXT_MISMATCH",
        message: `Proof context mismatch for ${individualProof.proofType}`
      });
    }

    // 4c. Check accumulator epoch (non-revocation). This common branch runs
    //     after the v1/v2 binding dispatch above, so v2 selective-disclosure
    //     entries are authenticated against metadata-v2 announcements here.
    const issuer = extractIssuer(individualProof);
    const currentAccumulator = await verifier.getAccumulator(issuer);

    if (individualProof.proofType === "zkc:proof:selective_disclosure:v1" &&
        !currentAccumulator.authenticatedMetadataV2) {
      errors.push({
        code: "UNAUTHENTICATED_ACCUMULATOR",
        message: "Selective-disclosure root was not accepted through authenticated metadata v2"
      });
      continue;
    }

    // MAX_EPOCH_LAG: verifiers accept proofs from within 10 accumulator epochs of current.
    // Epoch duration is issuer-defined; recommended minimum update frequency is daily.
    const MAX_EPOCH_LAG = 10;

    if (individualProof.publicInputs.accumulator_epoch > currentAccumulator.epoch) {
      errors.push({
        code: "STALE_ACCUMULATOR",
        message: `Accumulator epoch is ahead of verifier view for ${individualProof.proofType}`
      });
      continue;
    } else if (individualProof.publicInputs.accumulator_epoch < currentAccumulator.epoch - MAX_EPOCH_LAG) {
      errors.push({
        code: "STALE_ACCUMULATOR",
        message: `Accumulator epoch too old for ${individualProof.proofType}`
      });
    }
    if (individualProof.proofType === "zkc:proof:selective_disclosure:v1" &&
        currentAccumulator.roots[individualProof.publicInputs.accumulator_epoch] !==
          individualProof.publicInputs.accumulator_root) {
      errors.push({
        code: "STALE_ACCUMULATOR",
        message: "Selective-disclosure root does not match an authenticated announcement"
      });
    }

    // 4d. Check issuer trust
    if (!verifier.trustPolicy.trusts(issuer, individualProof.proofType)) {
      errors.push({
        code: "UNTRUSTED_ISSUER",
        message: `Issuer not trusted for ${individualProof.proofType}`
      });
    }

    // 4e. Check type-specific requirements
    const typeErrors = verifyTypeSpecificRequirements(
      individualProof,
      request
    );
    errors.push(...typeErrors);
  }

  // 5. Check all required proof types are present
  for (const required of request.requirements.proofTypes) {
    if (!proof.proofs.some(p => p.proofType === required)) {
      errors.push({
        code: "MISSING_PROOF",
        message: `Required proof type missing: ${required}`
      });
    }
  }

  // 6. Verify aggregation (if present)
  if (proof.aggregation) {
    if (!verifyAggregation(proof.aggregation, proof.proofs)) {
      errors.push({ code: "INVALID_AGGREGATION", message: "Aggregation proof invalid" });
    }
  }

  return {
    valid: errors.length === 0,
    errors
  };
}

6.2.1 Type-specific request-binding contract (normative)

verifyTypeSpecificRequirements is the single step-4e dispatcher for individual proof entries. The authenticated path first establishes that the effective ProofRequest agrees with the signed APR; the checks below then use that request's parameters, context value, and timestamp as authority. Running these checks only from verifyAuthenticated is non-conformant: every ordinary verify entry point MUST reach the same dispatcher, while authenticated verification adds APR agreement before it.

The machine-readable, name-keyed bound/derived/free classifications in schemas/public-input-bindings/ and their ratified transcription in docs/zkc_public_input_classification.md define the complete public-input contract. Steps 3, 4b, 4c, and 4d enforce the common envelope/profile, context, accumulator, timestamp, and issuer-key rows; step 4e enforces the family-specific rows below. A conforming implementation MUST NOT infer a field's meaning from its encoder offset, treat a derived field as a substitute for a separate bound field, or treat a justified free field as a satisfied verifier requirement.

Numeric request parameters are exact JSON integers, not field residues or coercible values. Before hashing or comparison, every value that enters a BN254 Field slot MUST be in [0, r), and values entering a narrower circuit type MUST also be in that type's range. In particular, an implementation MUST reject x + k*r for non-zero k instead of reducing it to x; it MUST also reject booleans, strings, and floats as integer aliases. The KYC requiredLevel and jurisdiction requiredClaimType are u8 values.

Individual proof family Mandatory step-4e behavior
KYC v1/v2 Require a u8 integer requiredLevel and exactly five boolean requiredAttributes; recompute claim_binding = compute_claim_binding_2(requiredLevel, attribute_flags_hash(requiredAttributes), request.context.value, request.context.timestamp).
Jurisdiction v1/v2 Require membershipType, requiredClaimType, and jurisdictionSetRoot; compare membership_type directly; compare jurisdiction_set_root directly; and recompute claim_binding = compute_claim_binding_3(expectedRoot, membershipType, requiredClaimType, request.context.value, request.context.timestamp). Exclusion additionally requires jurisdictionCanonicalSet, from which the verifier MUST independently derive expectedRoot and require agreement with jurisdictionSetRoot. Inclusion performs the same derivation when canonical codes are supplied; otherwise the mandatory request root is compared directly.
Accreditation v1/v2 Resolve predicate_type from the published proof-type map and require value 1; require required0, default required1 to 0, require set_root == 0, and recompute the three-slot claim binding. Because these immutable circuits do not bind flagRequired, a verifier MUST reject exact true and every non-boolean value with REQUIREMENTS_NOT_MET; only false/absent is usable.
Accreditation v3/v4 Apply the same selector, required-slot, and set_root == 0 checks; resolve flagRequired as an exact JSON boolean (absent defaults false); and recompute claim_binding = compute_claim_binding_4(predicateType, required0, required1, flagRequired, request.context.value, request.context.timestamp). The circuit MUST use the same private flag in both this transcript and its conditional check against issuer-signed claim_1.
Accreditation v7/v8 Apply the v3/v4 accreditation contract with the same four-slot transcript. These are the current hardened linkable/presentation identities; their credential digest also authenticates validFrom.
Source-of-funds v1/v2 Resolve and compare predicate_type == 2; default required0 / required1 to 0; require setRoot, compare it directly with set_root, and recompute the three-slot claim binding. set_root is outside that binding preimage, so the direct comparison is mandatory.
Source-of-funds v3/v4 Apply the v1/v2 checks, require flagRequired to be false when supplied, and recompute the four-slot claim binding with its fixed false flag.
Source-of-funds v7/v8 Apply the v3/v4 verifier checks. The circuit additionally MUST constrain the private raw source code to be non-zero before accepting Merkle membership, and the credential digest MUST authenticate validFrom.
Tax-residency v1/v2 Resolve and compare predicate_type == 3; default required0 to 0; require required1 and setRoot; compare set_root directly and recompute the three-slot claim binding.
Tax-residency v3/v4 Apply the v1/v2 checks, require flagRequired to be false when supplied, and recompute the four-slot claim binding with its fixed false flag.
Tax-residency v7/v8 Apply the v3/v4 verifier checks. The circuit additionally MUST constrain the private raw tax-jurisdiction code to be non-zero before accepting Merkle membership, and the credential digest MUST authenticate validFrom.
Sanctions-clearance v1/v2 Resolve and compare predicate_type == 4; default both required slots to 0; require canonicalSet and setRoot; independently derive the canonical exclusion root, require the two request parameters to agree, compare the derived root directly with set_root, and recompute the three-slot claim binding.
Sanctions-clearance v3/v4 Apply the v1/v2 checks, require flagRequired to be false when supplied, and recompute the four-slot claim binding with its fixed false flag.
Sanctions-clearance v7/v8 Apply the v3/v4 verifier checks. The circuit additionally MUST constrain successor_path_index == predecessor_path_index + 1; membership and strict ordering checks remain mandatory, and the credential digest MUST authenticate validFrom.
Selective disclosure v1 Require unique, in-range integer disclosedPositions; require the public flags to match those positions, disclosure_count to equal their population count and be non-zero, and recompute the typed disclosure binding. Credential attribute count and disclosed attribute values are authenticated proof outputs, not request-chosen expectations.
Credential rebind (zkc:proof:credential_rebind:v1, zkc:proof:rebind:v2, zkc:proof:credential_rebind:v2, zkc:proof:rebind:v3) Reject unconditionally with UNAUTHENTICATED_CREDENTIAL before any backend verification (§4.5 security containment). These statements verify no issuer signature and no accumulator membership, so no request parameter, trust-policy entry, or authenticated request can turn them into evidence of credential continuity. The former structural checks (old/new handle bound to the envelope, claim-binding recomputation, optional issuerIdHash / schemaIdHash equality filters) never authenticated the credential and are retained in the reference verifier only as frozen, unreachable documentation of the historical statements.
Authenticated credential rebind v3 (linkable, zkc:proof:credential_rebind:v3) Apply the full issuer-authenticated path of steps 4b–4d exactly as for KYC: the public issuer key MUST be an exact allowlist member trusted for this proof type, the public issuer_id_hash MUST equal string_to_field of the matched issuer's identifier, and the accumulator state MUST be an authenticated (metadata-v2 announcement-chain) state for that issuer whose currentEpoch equals the proof's current_epoch, whose lag bound admits accumulator_epoch, and whose recorded root for that epoch equals accumulator_root (§7.4.5). Then bind old_holder_commitment to holderBinding.commitment, require new_holder_commitment != old_holder_commitment, recompute claim_binding = compute_claim_binding_3(old_holder_commitment, new_holder_commitment, credential_id, proofContext, timestamp), and apply the optional sidecar-declared schemaIdHash pin as an exact equality filter after issuer authentication. A missing, unauthenticated, stale, ahead-of-view, or mismatched accumulator state, an untrusted key, or an issuer-identity mismatch MUST reject; the request pin can only narrow acceptance, never widen it. issuerIdHash is not an optional pin here: it is a mandatory bound comparison against the verifier's own matched trusted issuer, which is strictly stronger than the filter the deprecated statements offered.
Authenticated credential rebind v4 (scoped, zkc:proof:rebind:v4) Same issuer-trust, issuer-identity, and authenticated-revocation checks as v3. The envelope MUST declare the scoped profile; scope_id MUST be non-zero, equal presentationBinding.scopeId, and (on the authenticated path) recompute from the verifier's identity per §5.9.4 rule 3; old_presentation_pseudonym MUST equal presentationBinding.pseudonym; new_presentation_pseudonym MUST differ; recompute claim_binding = compute_claim_binding_3(old_presentation_pseudonym, new_presentation_pseudonym, credential_id, proofContext, timestamp); apply the optional schemaIdHash pin after authentication.

For the remaining-predicate v3-v8 rows, compute_claim_binding_4(a, b, c, flag, context, timestamp) is exactly Poseidon3(Poseidon2(Poseidon4(DOMAIN_ZKC_CLAIM, a, b, c), flag ? 1 : 0), context, timestamp). Implementations MUST reject non-boolean flag aliases before hashing. tests/vectors/zkc_core_vectors.json pins both flag values.

The two aggregation proof identifiers describe envelope-level aggregation blocks and are verified by step 6; they have no individual step-4e contract. If either is presented as an individual proof entry, or if any known individual proof type lacks a contract, verification MUST fail with UNBOUND_PROOF_TYPE. Missing, non-object, malformed, internally inconsistent, or mismatched request parameters produce REQUIREMENTS_NOT_MET. Missing or undecodable public inputs produce MALFORMED_PROOF. Unknown proof identifiers remain UNKNOWN_PROOF_TYPE at the earlier registry-resolution step.

New structured error codes (verifier accountability). Step 0 introduces UNSIGNED_REQUEST (the request is not a validly signed, unexpired, fresh APR) and PURPOSE_MISMATCH (a proof bound under a different declared purpose, caught before the existing CONTEXT_MISMATCH path). Both join the existing STALE_PROOF / CONTEXT_MISMATCH structured error family and the conformance runner (§9.2). They are additive: STALE_PROOF, CONTEXT_MISMATCH, and every other existing code retain their semantics, and proof-envelope compatibility is unchanged. The reference verifier implements step 0 as an additive verify_authenticated(apr, envelope, request) entrypoint, leaving the existing verify(envelope, request) path (and its codes) byte-for-byte unchanged for existing proofs and bundles.

Request-binding error code. UNBOUND_PROOF_TYPE means a known individual proof type has no registered step-4e contract. It is distinct from UNKNOWN_PROOF_TYPE (no registry circuit), REQUIREMENTS_NOT_MET (a known contract whose request requirements cannot be resolved or do not match), and MALFORMED_PROOF (public inputs cannot be decoded). Implementations MUST NOT replace UNBOUND_PROOF_TYPE with silent acceptance or a permissive fallback.

Credential-authentication error code. UNAUTHENTICATED_CREDENTIAL means the exchange names a proof identity that asserts credential continuity without authenticating issuance or live revocation state — today the four contained credential-rebind identities of §4.5. It is raised before backend verification and before any request-binding check, on the unauthenticated and the authenticated entry point alike, and it is independent of the trust policy and of any request pin: backend proof validity MUST NOT be reported as credential continuity for such an identity. Implementations MUST NOT downgrade it to a warning or to a trust-policy decision.

6.3 Integration Patterns

// Exchange integration
class ExchangeComplianceGate {
  private verifier: ZKCVerifier;

  // Check compliance before allowing deposit
  async checkDeposit(
    userProof: ComplianceProof,
    depositAmount: u64
  ): Promise<DepositDecision> {
    const request: ProofRequest = {
      requirements: {
        proofTypes: ["zkc:kyc:v1", "zkc:source:v1", "zkc:sanctions:v1"],
        parameters: {
          "zkc:kyc:v1": { minLevel: "enhanced" },
          "zkc:source:v1": { acceptableSources: ["employment_income", "regulated_exchange"] },
          "zkc:sanctions:v1": { requiredLists: ["OFAC", "EU"] }
        },
        maxProofAge: Duration.hours(24),
        context: this.generateContext()
      },
      trustPolicy: this.trustPolicy
    };

    const result = await this.verifier.verify(userProof, request);

    if (result.valid) {
      return { allowed: true };
    } else {
      return { allowed: false, reasons: result.errors };
    }
  }
}

// DeFi protocol integration
class ComplianceDeFiVault {
  private verifier: ZKCVerifier;

  // On-chain compliance check (via oracle or ZK verification)
  async checkAccess(
    userAddress: Address,
    userProof: ComplianceProof
  ): Promise<boolean> {
    // Verify proof off-chain
    const result = await this.verifier.verify(userProof, this.accessPolicy);

    if (!result.valid) return false;

    // Record compliance attestation on-chain
    // (doesn't reveal proof contents, just that verification passed)
    await this.recordComplianceAttestation(
      userAddress,
      hash(userProof),
      this.verifier.id
    );

    return true;
  }
}

7. Circuit Specifications

7.1 KYC Proof Circuit

KYC PROOF CIRCUIT
═════════════════

Public Inputs:
  - holderCommitment: Hash
  - claimBinding: Hash
  - proofContext: Hash
  - accumulatorRoot: Hash
  - accumulatorEpoch: u64
  - timestamp: Timestamp
  - currentEpoch: u64
  - issuerPublicKeyX: Bytes32
  - issuerPublicKeyY: Bytes32

Private Inputs:
  - credential: KYCCredential
  - credentialBindingKey: Scalar
  - accumulatorWitness: AccumulatorWitness
  - requiredLevel: u8
  - requiredAttributes: [bool; 5]

Constraints:
  1. BINDING VERIFICATION
     holderCommitment == H("zkc/binding", credentialBindingKey)
     credential.body.holderCommitment == holderCommitment

  2. SIGNATURE VERIFICATION
     verify_signature(credential, credential.signature)

  3. NON-REVOCATION
     verify_accumulator_membership(
       credential.body.credentialId,
       accumulatorWitness,
       accumulatorEpoch
     )

  4. LEVEL CHECK
     credential.body.claims.level >= requiredLevel

  5. ATTRIBUTE CHECK
     for i in 0..5:
       if requiredAttributes[i]:
         credential.body.claims.verifiedAttributes[i] == true

  6. EXPIRY CHECK
     credential.header.expiresAt > timestamp OR credential.header.expiresAt == null

  7. CLAIM BINDING
     claimBinding == H("zkc/claim", requiredLevel, requiredAttributes, proofContext, timestamp)

Parameters:
  - Proving system: Noir / UltraHonk (BN254)
  - Rows: ~2^17
  - Proving time: ~3s (native), ~5s (WASM)
  - Proof size: ~6 KB
  - Verification time: ~15ms

The zkc_kyc Noir package exposes the public inputs in the order shown above. The zkc:proof:v1 envelope maps them into publicInputs.{holder_commitment, claim_binding, proof_context, accumulator_root, accumulator_epoch, timestamp, current_epoch, issuer_public_key_x, issuer_public_key_y}. The proof body remains the UltraHonk proof artifact; the envelope is an SDK or verifier serialization of the proof plus these public inputs.

7.2 Jurisdiction Proof Circuit

JURISDICTION PROOF CIRCUIT
══════════════════════════

Public Inputs:
  - holderCommitment: Hash
  - claimBinding: Hash
  - proofContext: Hash
  - jurisdictionSetRoot: Hash
  - membershipType: u8  (0 = inclusion, 1 = exclusion)
  - accumulatorRoot: Hash
  - accumulatorEpoch: u64
  - timestamp: Timestamp
  - currentEpoch: u64
  - issuerPublicKeyX: Bytes32
  - issuerPublicKeyY: Bytes32

Private Inputs:
  - credential: JurisdictionCredential
  - credentialBindingKey: Scalar
  - accumulatorWitness: AccumulatorWitness
  - jurisdictionMerklePath: MerklePath

Constraints:
  1. BINDING VERIFICATION (same as KYC)

  2. SIGNATURE VERIFICATION (same as KYC)

  3. NON-REVOCATION (same as KYC)

  4. JURISDICTION SET MEMBERSHIP
     if membershipType == 0:  // inclusion
       verify_merkle_membership(
         credential.body.claims.jurisdiction,
         jurisdictionMerklePath,
         jurisdictionSetRoot
       )
     else:  // exclusion
       verify_merkle_non_membership(
         credential.body.claims.jurisdiction,
         jurisdictionMerklePath,
         jurisdictionSetRoot
       )

  5. VALIDITY PERIOD
     credential.body.claims.validFrom <= timestamp
     credential.body.claims.validUntil >= timestamp

  6. CLAIM BINDING
     claimBinding == H("zkc/claim", jurisdictionSetRoot, membershipType, proofContext, timestamp)

Parameters:
  - Rows: ~2^17
  - Proving time: ~3s
  - Proof size: ~6 KB

The initial zkc_jurisdiction Noir profile implements inclusion as Merkle membership in jurisdictionSetRoot and exclusion as a sorted-neighbor non-membership proof against a blocked-set root, which is sound only over a canonically constructed set (§7.2c). The zkc:proof:v1 envelope maps the public inputs into publicInputs.{holder_commitment, claim_binding, proof_context, jurisdiction_set_root, membership_type, accumulator_root, accumulator_epoch, timestamp, current_epoch, issuer_public_key_x, issuer_public_key_y}.

7.2a Remaining Predicate Profiles

The zkc_remaining_predicates and _v2 Noir packages define the immutable initial circuit profiles for the remaining ZKC-Standard/ZKC-Full predicates. The additive _v3 linkable and _v4 anonymous/scoped packages bind the exact flagRequired request bit without changing the issuer credential digest. The published _v5 linkable and _v6 anonymous/scoped packages retain their existing identities. Additive _v7 linkable and _v8 anonymous/scoped packages retain that four-slot transcript and public ABI while combining the raw-code, exact-adjacency, and validity-bound credential protections:

All eight profiles share the same base constraints: holder binding, issuer signature verification, non-revocation, credential validity period, proof context binding, and claim binding. In v3-v8, the private flag_required value participates in the four-slot public claim binding and, on accreditation, gates the check of issuer-signed claim_1. V7/v8 authenticate validFrom in the issuer credential digest, require source/tax raw codes to be non-zero, and require sanctions neighbour indices to be consecutive. Existing v1 credentials remain usable only for historical verification; current proof generation requires the issuer-authenticated v2 credential schema. Each proof remains atomic; aggregation MUST preserve separate predicate consent.

The v1-v6 circuit identities are immutable and remain available only for historical verification under an explicit policy that accepts deprecated entries. A holder MUST resolve new linkable remaining-predicate proofs to v7 and new anonymous/scoped proofs to v8; it MUST NOT fall back to v1-v6 when a current entry is unavailable.

7.2b Issuer-Authoritative Selective Disclosure

The zkc_selective_disclosure_v1 circuit proves holder binding, anonymous or scoped presentation binding, an issuer signature over the complete fixed-slot attribute credential, fresh non-revocation, exact positional disclosure, and a typed transcript bound to proofContext and timestamp.

Public Inputs:

presentation_pseudonym, scope_id, claim_binding, proof_context,
accumulator_root, accumulator_epoch, timestamp, current_epoch,
issuer_public_key_x[32], issuer_public_key_y[32],
credential_attribute_count, disclosure_count,
disclosed_flags[8], disclosed_attributes[8]

The circuit MUST reject issuer-signature mismatch, noncanonical credential padding, disclosure outside the active count, public values that differ from their issuer-signed positions, nonzero hidden values, count/mask mismatch, expired or revoked credentials, stale accumulator state, and transcript or presentation mismatch. A conforming verifier MUST require principal consent, enforce the versioned type-specific proof-entry schema without additional fields, and require the proof's accumulator epoch/root to exactly match an authenticated issuer root accepted under §2.4.1. In particular, metadata v1, an unsigned convenience field, a self-asserted metadata key, a malformed or stale v3 announcement, or a metadata/announcement mismatch is not an authenticated root; missing or downgraded root data is a fail-closed rejection. See docs/zkc_selective_disclosure.md.

7.2c Canonical Blocked-Set Construction and Root Distribution

This section is normative for jurisdiction exclusion and sanctions-clearance blocked sets. It also defines the reference canonical construction available to jurisdiction inclusion. The public API is exported from zkc_crypto; its deterministic conformance vectors and implementation are part of the repository's PUBLIC_ALLOWLIST.

Source-of-funds and tax-residency are not covered by this u16 protocol. Their private codes are full Field values. In deprecated remaining-predicate profiles v1-v4, raw code 0 aliases an empty padding leaf. Current v7/v8 profiles close that ambiguity by constraining the raw code to be non-zero without narrowing the full field domain; for example, code 70000 remains valid. They retain the direct request-root binding of §6.2. Applying this builder to them would truncate their domain and is non-conforming.

7.2c.1 Why construction and distribution are protocol requirements

The exclusion branches prove non-membership by exhibiting two blocked-set leaves that contain a subject between them. A Merkle root commits to leaf values and positions, but does not attest that the values were unique, sorted, consecutive, or bounded by sentinels. A verifier that accepts an opaque root cannot determine whether its tree has the structure required by the proof.

The verifier therefore MUST publish both the canonical code list and its derived root in the signed Authenticated Proof Request (APR). The holder and verifier MUST independently rebuild the same tree. A root literal by itself is not evidence of canonical construction, and an exclusion request containing only a root MUST be rejected. The verifier MUST compare the rebuilt root directly with the proof public input jurisdiction_set_root or set_root; recomputing only claim_binding is insufficient because the remaining- predicate claim-binding preimage does not contain set_root.

The reference implementation is python/zkc_crypto/canonical_set.py. tests/vectors/canonical_set_vectors.json and tests/vectors/validate_canonical_set_vectors.py pin the construction, positive witnesses, boundary behavior, and malformed-input refusals.

7.2c.2 Modes and canonical procedure

The construction mode is mandatory:

Given an array of codes and a mode, a conforming implementation MUST:

  1. Reject a non-array input, a non-integer or boolean entry, a reserved or out-of-domain code, and an offline construction input longer than 65,534 entries before deduplication.
  2. Deduplicate repeated codes; duplicates are not otherwise an error.
  3. Sort the member codes in ascending numeric order.
  4. In exclusion mode only, prepend code 0 and append code 65535.
  5. Place the resulting leaves at consecutive indices beginning with index 0.
  6. Encode every occupied leaf as specified by §7.2c.4.
  7. Pad the rest of the depth-32 tree using §7.2c.3.

The construction is deterministic. Input ordering and repetition do not alter the root. Inclusion and exclusion over the same member codes intentionally produce different roots because only exclusion has sentinel leaves.

Empty arrays are valid. Empty exclusion produces the two sentinel leaves, which bracket every valid subject. Empty inclusion occupies no leaves, has root zeroSubtree[32], and cannot yield a membership witness.

7.2c.3 Code domain, sentinels, depth, and padding

Jurisdiction and sanctions-clearance codes are u16. The canonical domain is:

Role Code
Minimum exclusion sentinel 0
Valid member and subject codes 1 .. 65534
Maximum exclusion sentinel 65535

Codes 0 and 65535 are reserved in both modes and MUST NOT be supplied as members or subjects. A deployment needing either value MUST remap its code space. Sentinels are encoded like other occupied leaves but are structural brackets, not policy members.

Trees are depth-32 Poseidon2 Merkle trees. A path index is consumed least- significant-bit first, with bit i selecting left or right at tree level i. Padding uses the recursive zero-subtree convention:

zeroSubtree[0]     = 0
zeroSubtree[k + 1] = H(zeroSubtree[k], zeroSubtree[k])

An absent subtree of height k contributes zeroSubtree[k], not a literal zero except at height 0. Consequently, a one-member canonical tree is not the same as a one-leaf tree whose 32 siblings are all literal zero.

For raw sanctions leaves, the minimum sentinel encodes to field element 0, the same value as an empty leaf. Implementations MUST use the occupied index, not the leaf value alone, to identify the sentinel. Its fixed position at index 0 and the maximum sentinel after the last member preserve the bracket invariant.

7.2c.4 Predicate leaf encodings

Predicate Mode Leaf encoding
Jurisdiction Inclusion or exclusion H(code, requiredClaimType)
Sanctions clearance Exclusion code as a field element

requiredClaimType MUST be an integer that fits in u8 and is folded into every occupied jurisdiction leaf, including exclusion sentinels. A jurisdiction root is therefore scoped to one claim type. Sanctions roots have no leaf-level domain separator and MUST NOT be reused for another predicate.

7.2c.5 Authenticated request parameter contract

For jurisdiction exclusion, the signed APR parameters for the proof type MUST contain all of:

membershipType: 1
requiredClaimType: <u8 integer>
jurisdictionCanonicalSet: <array of canonical-domain codes>
jurisdictionSetRoot: <BN254 field integer>

For sanctions clearance, the signed APR parameters MUST contain:

canonicalSet: <array of canonical-domain codes>
setRoot: <BN254 field integer>

The root fields distribute the verifier-selected root to the holder. The code arrays make that root independently derivable. Both are required for exclusion and MUST agree. The holder MUST verify the APR signature and effective-request agreement, derive the root, compare it with the APR root, and build the membership or adjacent-neighbour witness locally before proving. APR-supplied path indices or sibling arrays are not authoritative and MUST NOT override the locally derived witness.

An online APR code array MUST contain no more than 4,096 entries before deduplication. This request-processing budget is intentionally tighter than the 65,534-code offline construction limit: a valid signature authenticates the sender but does not make its computational workload safe. Holders and verifiers MUST reject an oversized signed array before copying or hashing it. The reference limit is exported as CANONICAL_SET_MAX_REQUEST_CODES.

The verifier MUST perform the same derivation from its authenticated request, compare the result with the APR root, then compare that result directly with the proof public input. A mismatch at any comparison is a rejection. A bare root fallback, warning-only provenance, or use of an operator-supplied root without derivation is non-conforming for exclusion.

Jurisdiction inclusion MAY retain the established root-only request shape, because it does not use the sorted-neighbour argument. If jurisdictionCanonicalSet is present, jurisdictionSetRoot MUST also be present and equal the canonical inclusion root, and the holder/verifier MUST perform the same independent derivation and comparison. The inclusion code array contains no sentinels.

canonicalSet is reserved for sanctions and jurisdictionCanonicalSet for jurisdiction. Supplying either reserved key to source-of-funds, tax-residency, accreditation, or another unsupported predicate, or supplying the wrong key to a supported predicate, MUST be rejected rather than silently ignored.

7.2c.6 Witness behavior and boundary cases

For inclusion, the holder MUST refuse to produce a membership witness for an absent code. For exclusion, it MUST refuse to produce a non-membership witness for a present code. A subject below the smallest member is bracketed by the minimum sentinel and that member; a subject above the largest is bracketed by the largest member and the maximum sentinel. The empty exclusion set uses the two sentinels directly.

The reference builder returns consecutive predecessor and successor indices. The verifier's canonical derivation guarantees that an honest holder receives the same root and can produce its witness without APR-distributed paths.

7.2c.7 Implementation status of the circuit adjacency constraint

This subsection records a current implementation limitation; it does not weaken the construction or request obligations above.

The historical remaining-predicate v1-v4 and jurisdiction v1/v2 circuits verify membership of both neighbours and the inequality predecessor < subject < successor, but do not constrain successor_path_index == predecessor_path_index + 1. Canonical construction and derived-root binding prevent substitution of a different tree, but cannot make those circuits inspect adjacency. A malicious prover can use two non-adjacent leaves from the honest canonical tree to bracket a code that is actually present.

The remaining-predicate v5-v8 sanctions branches and jurisdiction v3/v4 exclusion branches additionally constrain the successor index to equal the predecessor index plus one. They are sound only when combined with every canonical construction and authenticated-root obligation above. Implementations MUST NOT describe historical remaining-predicate v1-v4 or jurisdiction v1/v2 exclusion proofs as sound against a malicious prover.

7.2c.8 Implementer note (non-normative)

The external construction obligation is a consequence of proving non-membership with sorted neighbours over a plain Merkle tree. An indexed accumulator whose leaf commits to its successor could let the circuit attest adjacency directly, at the cost of a different leaf format and update procedure. Such a circuit is outside this version.

7.3 Aggregation Circuit

PROOF AGGREGATION CIRCUIT
═════════════════════════

Public Inputs:
  - inputProofCommitments: Hash[]
  - aggregatedClaimBinding: Hash
  - holderCommitment: Hash

Private Inputs:
  - inputProofs: ZKProof[]
  - inputPublicInputs: PublicInputs[]

Constraints:
  1. VERIFY EACH INPUT PROOF
     for i in 0..n:
       verify_proof(inputProofs[i], inputPublicInputs[i])

  2. SAME HOLDER
     for i in 0..n:
       inputPublicInputs[i].holderCommitment == holderCommitment

  3. COMMITMENT CONSISTENCY
     for i in 0..n:
       inputProofCommitments[i] == H(inputProofs[i])

  4. CLAIM BINDING
     aggregatedClaimBinding == H(inputProofCommitments)

Parameters:
  - Rows: ~2^15 + n * 2^14 (grows with input count)
  - Max inputs: 8 (configurable)
  - Recursive verification supported

The initial zkc_aggregation Noir package supports up to 8 atomic predicate proof slots. Until backend-native recursive verification is stable, it follows the ZKA simulated-recursion profile by committing to verification keys, proof bytes, public inputs, and proof type per slot. Aggregation requires all active proofs to share the same holderCommitment, requires an explicit consent flag per active proof, and derives aggregatedClaimBinding from the aggregation commitment and proof context. The JSON envelope schema is schemas/compliance-proof-v1.schema.json.

7.4 Authenticated Credential Rebind Circuits

This section specifies the additive, issuer-authenticated successors to the deprecated credential-rebind statements of §4.5. Design rationale is recorded in ADR 0004 (docs/adr/0004-authenticated-credential-rebind.md). Both successors prove, for one credential, that (a) the credential was created by a specific issuer for a specific holder commitment, schema, identifier, and validity window, (b) the credential is a live member of that issuer's non-revocation accumulator at a fresh epoch, (c) the prover controls the binding key behind that holder commitment, and (d) the prover controls the binding key behind a distinct new holder handle. Nothing about the credential is prover-chosen: every credential field in the statement is either public and compared by the verifier, or bound by the issuer's signature recomputed in-circuit.

7.4.1 The zkc:attestation:credential-rebind:v1 transcript

An issuer that permits a credential to be rebound signs a rebind attestation for it, at issuance time or later on the holder's request. The attestation is schema-agnostic: it binds the credential's identity rather than its schema-specific claims, so one circuit serves every credential schema.

REBIND_ATTESTATION_TYPE = string_to_field("zkc:attestation:credential-rebind:v1")
issuerIdHash            = string_to_field(issuerId)
schemaIdHash            = string_to_field(schemaId)

header = Poseidon4(DOMAIN_ZKC_CLAIM, REBIND_ATTESTATION_TYPE, issuerIdHash, schemaIdHash)
body   = Poseidon4(holderCommitment, credentialId, validFrom, validUntil)
digest = Poseidon2(header, body)

signature = ECDSA_secp256k1_sign(issuerSigningKey, digest.to_be_bytes(32))

The credential envelope (§4.3) carries the attestation as an additive rebindAttestation member of credential:

"rebindAttestation": {
  "version": "zkc:attestation:credential-rebind:v1",
  "schemaId": "zkc:cred:kyc:v1",
  "validFrom": 1000,
  "validUntil": 2000000,
  "signature": "0x…64 bytes…"
}

issuerId, holderCommitment, and credentialId are taken from the enclosing credential, so the attestation cannot name a different credential than the one it travels with.

7.4.2 zkc:proof:credential_rebind:v3 — linkable profile (zkc_credential_rebind_auth)

AUTHENTICATED CREDENTIAL REBIND CIRCUIT (linkable)
═══════════════════════════════════════════════════

Public Inputs (declaration order; 75 field elements):
  0  old_holder_commitment: Field
  1  new_holder_commitment: Field
  2  credential_id: Field
  3  issuer_id_hash: Field
  4  schema_id_hash: Field
  5  proof_context: Field
  6  timestamp: u64
  7  claim_binding: Field
  8  accumulator_root: Field
  9  accumulator_epoch: u64
  10 current_epoch: u64
  11 issuer_public_key_x: [u8; 32]
  12 issuer_public_key_y: [u8; 32]

Private Inputs:
  old_credential_binding_key: Field
  new_credential_binding_key: Field
  credential_valid_from: u64
  credential_valid_until: u64
  issuer_signature: [u8; 64]
  nonrevocation_siblings: [Field; 32]
  nonrevocation_path_index: Field
  statement_domain: Field

Constraints:
  1. STATEMENT IDENTITY
     statement_domain == string_to_field("zkc:proof:credential_rebind:v3")
  2. HOLDER CONTROL (old and new)
     old_holder_commitment == H("zkc/binding", old_credential_binding_key)
     new_holder_commitment == H("zkc/binding", new_credential_binding_key)
     old_holder_commitment != new_holder_commitment
  3. ISSUER AUTHENTICATION (recomputed transcript, no digest input)
     digest = rebind_attestation_digest(issuer_id_hash, schema_id_hash,
                                        old_holder_commitment, credential_id,
                                        credential_valid_from, credential_valid_until)
     verify_signature_secp256k1(issuer_public_key_x, issuer_public_key_y,
                                issuer_signature, digest.to_be_bytes())
  4. NON-REVOCATION (same credential_id as the transcript)
     verify_accumulator_membership(credential_id, nonrevocation_path_index,
                                   nonrevocation_siblings, accumulator_root)
     current_epoch >= accumulator_epoch
     current_epoch - accumulator_epoch <= MAX_EPOCH_LAG
  5. VALIDITY
     credential_valid_from <= timestamp
     credential_valid_until == 0 OR credential_valid_until >= timestamp
     timestamp != 0
  6. CONTEXT AND CLAIM BINDING
     proof_context != 0
     claim_binding == compute_claim_binding_3(old_holder_commitment, new_holder_commitment,
                                              credential_id, proof_context, timestamp)

The proof is carried in a zkc:proof:v1 envelope whose holderBinding.commitment MUST equal old_holder_commitment (the rebind is presented under the holder's current identity).

7.4.3 zkc:proof:rebind:v4 — scoped profile (zkc_credential_rebind_auth_v2)

AUTHENTICATED CREDENTIAL REBIND CIRCUIT (scoped)
═════════════════════════════════════════════════

Public Inputs (declaration order; 76 field elements):
  0  old_presentation_pseudonym: Field
  1  new_presentation_pseudonym: Field
  2  scope_id: Field
  3  credential_id: Field
  4  issuer_id_hash: Field
  5  schema_id_hash: Field
  6  proof_context: Field
  7  timestamp: u64
  8  claim_binding: Field
  9  accumulator_root: Field
  10 accumulator_epoch: u64
  11 current_epoch: u64
  12 issuer_public_key_x: [u8; 32]
  13 issuer_public_key_y: [u8; 32]

Private Inputs:
  old_holder_commitment: Field
  new_holder_commitment: Field
  old_credential_binding_key: Field
  new_credential_binding_key: Field
  credential_valid_from: u64
  credential_valid_until: u64
  issuer_signature: [u8; 64]
  nonrevocation_siblings: [Field; 32]
  nonrevocation_path_index: Field
  statement_domain: Field

Constraints:
  1. STATEMENT IDENTITY
     statement_domain == string_to_field("zkc:proof:rebind:v4")
  2. SCOPED PRESENTATION (no anonymous rebind)
     scope_id != 0
     old_presentation_pseudonym == H("zkc/presentation-scope:v1", old_credential_binding_key, scope_id)
     new_presentation_pseudonym == H("zkc/presentation-scope:v1", new_credential_binding_key, scope_id)
     old_presentation_pseudonym != new_presentation_pseudonym
  3. HOLDER CONTROL over the (private) commitments — as 7.4.2 constraint 2
  4. ISSUER AUTHENTICATION — as 7.4.2 constraint 3, over the private old_holder_commitment
  5. NON-REVOCATION — as 7.4.2 constraint 4
  6. VALIDITY — as 7.4.2 constraint 5
  7. CONTEXT AND CLAIM BINDING
     proof_context != 0
     claim_binding == compute_claim_binding_3(old_presentation_pseudonym, new_presentation_pseudonym,
                                              credential_id, proof_context, timestamp)

The proof is carried in a scoped zkc:proof:v2 envelope whose presentationBinding.pseudonym MUST equal old_presentation_pseudonym and whose presentationBinding.scopeId MUST equal scope_id (§5.9.4 rules 2–4).

7.4.4 Holder rules

7.4.5 Verifier rules

The successor rows of the §6.2 type-specific table are normative. In particular a verifier MUST require, for the proof's public issuer key, an exact trust-policy match that is trusted for the proof type; MUST require issuer_id_hash == string_to_field(issuerId) of the matched issuer; MUST hold an authenticated accumulator state (§2.4, metadata v2 signed announcement chain) for that issuer and reject a proof whose current_epoch differs from its own view, whose accumulator_epoch is ahead of that view or older than the tighter of the policy and announcement lag bounds, or whose accumulator_root differs from the recorded root for that epoch; and MUST apply the optional schemaIdHash request pin only after these checks. The per-field contract is machine-readable in schemas/public-input-bindings/zkc_credential_rebind_auth{,_v2}.json, where issuer_id_hash is bound (compared against the verifier's own matched issuer identifier) rather than an optional filter. A prover-controlled current_epoch or accumulator_root is never revocation authentication, and an Authenticated Proof Request never substitutes for issuer authentication. verify() and verify_authenticated() MUST apply identical semantics.

7.4.6 Migration, recovery, privacy, and vectors


8. Security Considerations

8.1 Cryptographic Assumptions

Assumption Consequence if Broken
Discrete log (BN254) Credential binding key recovery
Poseidon collision resistance Credential forgery
ZK proving system soundness (UltraHonk/Barretenberg) Invalid proofs accepted if broken
BLS signature security Credential signature forgery
Accumulator security Revocation bypass

8.2 Privacy Properties

Property Guarantee
Credential binding pseudonymity holderCommitment = H("zkc/binding", cbk) hides the holder's seed and real-world identity from the verifier, but it is a stable, deterministic, cross-verifier-linkable pseudonymous anchor, not an unlinkability mechanism. The v2 anonymous profile removes the linkable anchor from default presentations entirely (see §5.9); a scoped presentation exposes only a scope-exclusive presentationPseudonym.
Seed-path unlinkability The ZKA value-address and ZKC credential-commitment derive along separate KDC A.6 paths and are unlinkable without the seed. This is not cross-verifier multi-show unlinkability.
Issuer privacy Verifier learns issuer type, not specific issuer (configurable)
Claim privacy Only requested claims are proven, others hidden
Temporal privacy Credential issuance date hidden

Known limitation / open item. This equality-linkability limitation applies to v1/linkable envelopes (zkc:presentation:linkable:v1): two presentations of the same credential that expose the same holderCommitment are linkable by equality, including by colluding verifiers. Which presentation profile governs a given presentation now determines the exposure (§5.9): the default anonymous profile exposes no holder-derived handle (pseudonym and scope are 0), a scoped presentation exposes only a scope-exclusive presentationPseudonym, and the stable holderCommitment is exposed only under the explicitly consented linkable profile. Genuine cross-verifier multi-show unlinkability is thus a matter of profile choice; the accepted design direction and its ladder are recorded in docs/adr/0001-multi-show-unlinkable-presentations.md.

8.3 Attack Mitigations

Attack Launch mitigation and residual risk
Credential sharing Binding key tied to spending key.
Proof replay Context binding and timestamps.
Issuer collusion Verifier-controlled trust policies.
Accumulator stalling Epoch freshness requirements reject an announcement whose signed announcedAt or epoch lag is outside local policy. This is a partial launch mitigation: a verifier cannot prove that an issuer has omitted a newer state that the verifier has never observed.
Announcement omission Historical backfill and fail-closed gap handling expose an omitted intermediate epoch to a verifier that later receives a successor. This is a partial launch mitigation: a party kept on an internally consistent prefix cannot distinguish omission from an idle issuer without an independent observation.
Announcement replay The signed timestamp, monotonic epoch, and persisted local checkpoint reject an older announcement after a newer one has been accepted. Cold start still depends on freshness policy and cannot establish that no fresher view exists elsewhere.
Revocation split view / issuer equivocation A pinned signing key prevents third-party substitution, and the complete predecessor-announcement commitment makes a conflicting successor detectable once compared with a verifier's persisted checkpoint. This is a partial launch mitigation, not global consistency: a malicious issuer can sign and serve two individually valid histories to isolated parties. The conflicting signed announcements are evidence of equivocation only when the views meet through direct comparison, monitoring, or gossip.
Timing correlation Randomized proof generation.

These guarantees are deliberately distinct. Signature verification establishes third-party substitution resistance; predecessor and checkpoint comparison provide locally observed fork detection; neither supplies global split-view detection. A verifier MUST NOT infer that its valid local chain is the issuer's only chain.

Post-launch work specifies a per-issuer CT-v2-style append-only revocation log. Log inclusion and consistency proofs can establish membership and consistency within the view a log serves, but consistency proofs alone do not reveal two isolated, internally consistent views. Global split-view detection therefore also requires independent monitors and cross-party checkpoint gossip.

The rows above are normative launch requirements. The reference issuer now publishes signed v3 metadata, and its durable service variant persists the ordered announcement history. The verifier and private witness provider now enforce pre-pinned key and accumulator ids, exact metadata equality, signed freshness, bounded ordered backfill, durable checkpoints, replay/linkage rejection, and signed conflict evidence retention. End-to-end launch conformance additionally requires passing the authenticated-revocation conformance suite.

8.4 Metadata Leakage (Non-Normative Pointer)

The tables above address cryptographic attacks. Metadata that leaks AROUND the cryptography — predicate type, issuer choice, proof timing, verifier endpoints, credential-freshness queries, registry lookups — is analyzed systematically (per flow × observer, with implemented mitigations and honest residual risk) in the reference threat model: docs/zkc_metadata_leakage_threat_model.md. Implemented reference mitigations include local-copy registry reads, credential-anonymous witness refresh (zkc:epoch-delta-request:v1), and constant-shape refusals; the reference test suite pins these behaviors (tests/python/test_metadata_leakage.py). This section is a pointer only — it changes no envelope, circuit, or verification rule.


9. Conformance

9.1 Conformance Levels

Core conformance is a strictly monotonic ladder; each level is a superset of the one below it. ZKC-Full is the highest core level.

Level Requirements
ZKC-Core KYC proofs, basic verification
ZKC-Standard Core + jurisdiction, accreditation, source
ZKC-Full Standard + tax, sanctions, aggregation

ZKC-Agent is an orthogonal extension profile — not a level above ZKC-Full. It denotes a deployment that exposes the agent API (§5.4) for automated compliance on top of any claimed core level (typically ZKC-Full). Tooling MUST NOT infer an ordering in which ZKC-Agent ranks above ZKC-Full, and MUST treat the level name as ZKC-namespaced rather than inferring rank from any other protocol's conformance taxonomy.

9.2 Test Vectors

Canonical ZKC core test vectors are published in tests/vectors/zkc_core_vectors.json. Implementations MUST treat these as golden vectors for the BN254/Poseidon2 profile: domain separators, Poseidon2 KATs, KDC A.6 credential binding derivation, holder commitment derivation, claim binding examples, and the purpose-bound context KATs (purposeTag derivation and the purpose-bound context preimage, §2.2) MUST match byte-for-byte.

The same vector file contains the selective_disclosure_v1 issuer-digest and typed-transcript KATs plus the required negative-case inventory, and the credential_rebind_attestation_v1 KATs for the issuer-authenticated rebind transcript, statement domains, scoped pseudonyms, and claim bindings of §7.4. The transcript uses the AFP-KDC v1.0.3 current registry's zkc/claim operational domain with an explicit zkc:proof:selective_disclosure:v1 type field; the domain field and Poseidon2 profile remain immutable AFP-KDC v1.0.2 crypto evidence.

Authenticated revocation launch conformance is pinned by tests/vectors/revocation_equivocation_v1.json and the network-independent conformance/run_revocation.py runner. The public deterministic vectors cover the exact signed transcript and complete predecessor hash, every signed-field tamper, metadata mismatch, unauthorized key substitution and rotation, replay, gap/backfill failure, durable restart, and signed fork evidence. They also pin the genesis-to-issuance-to-revocation adapter lifecycle. The cold-start case accepts a correctly pinned first announcement only as a local checkpoint; it rejects an untrusted or malformed first view and makes no global consistency claim. The two-view case demonstrates that isolated, internally consistent forks remain undetected until their signed checkpoints are compared.

A runnable conformance suite for the behavioral requirements (holder generation/refusal, verifier acceptance/rejection with structured error codes, and the executable Freedom Safeguard checks for Safeguards 1, 4, and 6) is published in conformance/ with machine-readable ZKC-Core vectors and a documented third-party adapter contract (conformance/README.md). Step-4e request binding is executable, not prose-only: KYC, jurisdiction, accreditation, source-of-funds, tax-residency, and sanctions-clearance each have an honest round-trip control plus a core-verifier-*-requirements-not-met case that leaves a valid proof untouched and verifies it against a different, internally valid request. A conforming verifier MUST reject each such out-of-request proof with REQUIREMENTS_NOT_MET; INVALID_PROOF does not satisfy these cases because no proof byte or public input was mutated. The Safeguard 6 (verifier accountability) coverage includes generation refusal on an unsigned request, PURPOSE_MISMATCH when a billing-purpose proof is presented to an access-purpose request, third-party Disclosure-Receipt round-trip verification with no operator in the loop, and hosted-SDK receipt-retention refusal (safeguard-6-* cases). The concrete canonical serialization of the purpose-bound, APR, and Disclosure-Receipt digests — in particular how the structured proofTypes and parameters members commit into the field-based preimage, and how the receipt binds the signed APR — is pinned byte-for-byte by the reference implementation (python/zkc_crypto/apr.py) and these vectors, which are authoritative for clean-room reproduction. Profiles above Core attach to the same runner as expansion hooks.


Appendix A: Well-Known Identifier Registry (Non-Exclusive)

This appendix is a non-exclusive registry of well-known identifiers published for interoperability. It is not a closed or mandated schema, and it does not define an "official" or "default" credential or predicate set (see §1.4, Safeguard 1).

Normatively:

Well-known credential and proof type identifiers (reference, non-exhaustive):

const CREDENTIAL_TYPES = {
  KYC_V1: "zkc:cred:kyc:v1",
  JURISDICTION_V1: "zkc:cred:jurisdiction:v1",
  ACCREDITATION_V1: "zkc:cred:accreditation:v1",
  SOURCE_V1: "zkc:cred:source:v1",
  TAX_V1: "zkc:cred:tax:v1",
  SANCTIONS_V1: "zkc:cred:sanctions:v1",
};

const PROOF_TYPES = {
  KYC_V1: "zkc:proof:kyc:v1",
  JURISDICTION_V1: "zkc:proof:jurisdiction:v1",
  ACCREDITATION_V1: "zkc:proof:accreditation:v1",
  SOURCE_V1: "zkc:proof:source:v1",
  TAX_V1: "zkc:proof:tax:v1",
  SANCTIONS_V1: "zkc:proof:sanctions:v1",
  // Historical, contained identifier (§4.5): authenticates no credential;
  // holders never generate it and verifiers reject it unconditionally.
  CREDENTIAL_REBIND_V1: "zkc:proof:credential_rebind:v1",
  // Issuer-authenticated credential rebind (§7.4): linkable and scoped.
  CREDENTIAL_REBIND_V3: "zkc:proof:credential_rebind:v3",
  REBIND_V4: "zkc:proof:rebind:v4",
};

const ATTESTATION_TYPES = {
  // Issuer-signed credential transcript consumed by the §7.4 rebind circuits.
  CREDENTIAL_REBIND_V1: "zkc:attestation:credential-rebind:v1",
};

Appendix B: Issuer Reputation (Future)

Framework for decentralized issuer reputation (draft):

// Reputation accumulates from:
// - Successful verifications (verifiers report)
// - Age of operation
// - Stake (if applicable)
// - Regulatory status

interface IssuerReputation {
  issuerId: IssuerIdentifier;

  metrics: {
    totalCredentialsIssued: u64;
    totalVerifications: u64;
    successfulVerifications: u64;

    // Decentralized reputation score
    reputationScore: u64;  // 0-1000

    // Time-weighted factors
    operatingSince: Timestamp;
    lastActivity: Timestamp;
  };

  // Verifier-submitted reports
  reports: {
    verifier: VerifierIdentifier;
    outcome: "success" | "failure" | "suspicious";
    timestamp: Timestamp;
  }[];
}

Document History

Version Date Changes
Unreleased 2026-09-06 Security containment of unauthenticated credential-rebind statements. §4.5 records that zkc:proof:credential_rebind:v1, zkc:proof:rebind:v2, zkc:proof:credential_rebind:v2, and zkc:proof:rebind:v3 prove holder-key continuity only (their credential fields are unconstrained witnesses; no issuer signature or accumulator membership is verified) and are never evidence of credential ownership or live continuity. Holders MUST NOT generate them; verifiers MUST reject them at every entry point with the new structured error UNAUTHENTICATED_CREDENTIAL (§6.2), independent of trust policy, request pins, or request authentication. Historical circuits, encoders, sidecars, artifacts, and registry pins are byte-frozen; the four registry entries are deprecated with no historical-verification exception. Authenticated credential rebind (§7.4, ADR 0004). Specifies the issuer-signed zkc:attestation:credential-rebind:v1 transcript (Poseidon2(Poseidon4(DOMAIN_ZKC_CLAIM, type, issuerIdHash, schemaIdHash), Poseidon4(holderCommitment, credentialId, validFrom, validUntil))), the additive linkable zkc:proof:credential_rebind:v3 and scoped zkc:proof:rebind:v4 circuits that recompute it from constrained fields, verify the issuer signature against the public key, and prove fresh non-revocation for the same credential identifier, their exact public/private input ABIs and claim bindings, the verifier's issuer-trust, issuer-identity, and authenticated-revocation acceptance rules (§6.2), the no-downgrade rule, the privacy analysis, migration of existing credentials, and the credential_rebind_attestation_v1 known-answer vectors (§9.2).
Unreleased 2026-09-03 Added the issuer-authoritative zkc:cred:attribute-set:v1 / zkc:proof:selective_disclosure:v1 reference profile, fixed-position Noir circuit, typed AFP-KDC-domain transcript, versioned proof-entry and public-input schemas, KATs, and ZKA/AFP opaque-consumer boundary. Canonicalized master seeds as exact 32-byte values under the frozen afp-kdc:master-seed:v1.0.1 big-endian-mod-r profile; added boundary/asymmetric vectors, strict host decoding, full-entropy generation, and representation-only migration rules without changing existing identities. Updated current authority to AFP-KDC v1.0.2, added seven raw-sponge KATs and the explicit 260-field parameter digest gate in Noir and Python, and synchronized the ZKM-aware protocol-family epoch without changing any derived value or frozen identity. Defined the step-4e verifyTypeSpecificRequirements request-binding contract, including default-deny UNBOUND_PROOF_TYPE behavior, and added honest plus out-of-request conformance vectors for every Core compliance predicate family. Clarified the delegation-token trust boundary: holder authorization MUST anchor token keys to authenticated local holder state, same-seed minting MUST bind the delegation key and holder commitment, and token-carried key coordinates remain compatibility metadata rather than a trust anchor. This clarification changes no token wire format, circuit, proof envelope, or delegated authority.
0.1.0 2026-01-19 Initial specification
0.2.0 2026-02-16 Proving system migration: Halo 2 (Pallas) → Noir/UltraHonk (BN254). Halo2Proof → ZKProof throughout. Protocol now proving-system-agnostic at spec level.
0.2.1 2026-03-06 Notation alignment with ZKA v0.5.0: H(x) updated to Poseidon2 over BN254 with domain tag; commit(v, r) updated from Pedersen to Poseidon2; §8.1 cryptographic assumptions removes stale Halo 2 reference.
0.2.2 2026-05-15 Alignment with ZKA v0.6.0 Freedom Safeguards (ZKA §1.6). Added §1.4 Freedom Safeguards (normative), including the open-source predicate-verification mandate (Safeguard 4) and the §2.1 ZKA §5.2 cross-reference. Reframed Appendix A as a non-exclusive well-known identifier registry and §3.1–3.6 as reference (non-mandatory) schemas per Safeguard 1 (Predicate Pluralism). Made §9.1 conformance monotonic: ZKC-Full is the highest core level; ZKC-Agent is an orthogonal extension profile, not a level above Full. Resolves the three ZKC-layer divergences in ZKA v0.6.0 Appendix C.1.
0.2.3 2026-06-04 Credential-binding-key derivation relocated to the Key Derivation Core (KDC v1.0.0, AFP Specification Appendix A) and referenced normatively from §2.1; §1.3 notation re-pointed from a ZKA-version reference to KDC §A.3. The CredentialBinding interface is unchanged. Relocation only — no change to any derived value, credential structure, circuit, or test vector.
0.2.5 2026-06-11 Registry governance: added §4.2a (reference registry layout, advisory/non-exclusive/immutable-version constraints, local-copy operation, membership-is-not-trust conformance hook) anchoring the in-repo registry/ and docs/zkc_predicate_registry_governance.md. Normative deltas restate §1.4 Safeguards 1/4 for registries; no change to credentials, circuits, proofs, or test vectors. Same-day addendum: non-normative §8.4 metadata-leakage pointer to docs/zkc_metadata_leakage_threat_model.md (no normative change).
0.2.4 2026-06-10 Align proof context binding with ZKA v0.6.2-draft transaction-absent work-attestation bundles. ProofContext is now profile-defined and hash-based, preserving verifier-session contexts while allowing AFP session_context to bind ZKC proofs carried in pure Coord/work-attestation bundles. Adds non-custodial delegated proof generation, service-boundary requirements, and sealed/minimized issuer audit mappings for credential rebinding. ZKA remains authoritative for zka:bundle:v1 substantiveness and coord_proof verification.
0.3.0 2026-06-13 Verifier accountability. Added Safeguard 6 — Verifier Accountability (§1.4, mirrored normatively from ZKA §1.6), the first safeguard binding the counterparty rather than the operator. Added the zkc:context:purpose-bound:v1 profile binding declared purpose into the proof context via purposeTag (§2.2); the Authenticated Proof Request (§5.5); the holder-custodied Disclosure Receipt (§5.6); the plaintext-disclosure scope rule (§5.7); and the normative residual limits — decision-accountability ceiling, verifier-identity continuity, verifier-deniability cost (§5.8). Added the verifier-side authenticated-request pre-step with UNSIGNED_REQUEST / PURPOSE_MISMATCH structured errors (§6.2) and the safeguard-6-* executable conformance vectors plus purpose-bound golden KATs (§9.2). No change to existing circuits, credentials, notes, bundles, or proof envelopes; all prior proofs remain valid instances and existing STALE_PROOF / CONTEXT_MISMATCH semantics are unchanged.
0.3.1-draft 2026-06-15 Co-publication cross-reference harmonization: the §2.2 work-attestation-bundle reference is retargeted from ZKA v0.6.2 to ZKA v0.7 (the co-published ZKA revision). No change to credentials, circuits, proofs, contexts, conformance, or test vectors — ZKC v0.3.0's verifier-accountability machinery (Safeguard 6, Authenticated Proof Request §5.5, Disclosure Receipt §5.6, purpose-bound context §2.2) already supports unknown-counterparty verification with no modification. See the AFP ADR for the cross-spec unknown-counterparty trust-model record.
0.3.2-draft 2026-07-02 Corrects the §8.2 privacy-properties claim for holderCommitment: current presentations are pseudonymous but linkable by equality when the stable commitment is public. Adds the multi-show unlinkability ADR pointer and records the scoped-pseudonym direction for a future presentation-profile change.
0.4.0-draft 2026-07-02 Presentation-profile ladder (implements amended ADR 0001). Adds §5.9 Presentation Profiles (normative): the three profiles (zkc:presentation:anonymous:v1 default, :scoped:v1, :linkable:v1), the pseudonym/scope/verifier-identity derivations, the APR presentation block, per-profile Disclosure Receipts, the verifier rules with new structured errors PROFILE_MISMATCH / SCOPE_MISMATCH / PROFILE_NOT_ACCEPTED, and the public-input determinism invariant as a MUST. Documents the zkc:proof:v2 envelope (presentationBinding, no holderBinding) in §6.1 and updates §8.2 (anonymous profile removes the linkable anchor from default presentations; the equality-linkability limitation applies to v1/linkable envelopes, governed by profile choice). v1 circuits/encoders/envelopes/registry entries stay byte-for-byte frozen; all changes are additive.
0.4.1-draft 2026-08-05 Documents the protocol-family boundary between ZKC eligibility, ZKM authorization, and ZKA settlement; assigns zkc/* and zkm/* domain-namespace ownership; identifies ProofContextProfile as the extension point for a future ZKM-defined profile; and clarifies that ZKC delegated proving and delegateViewing convey neither mandate nor spending authority. Documentation only: no change to credentials, circuits, schemas, proof envelopes, presentation profiles, conformance, or test vectors.

License

This specification is released under the Apache 2.0 License.

Patent Non-Assertion Covenant

The Apache 2.0 patent grant (License §3) covers patent claims necessarily infringed by a contribution alone or by its combination with the Work it was submitted to. An independent implementation of this specification — one written from the specification text without deriving from any reference code — falls outside that scope, and so is not clearly covered by that grant. The following covenant, modeled on the Open Web Foundation Agreement (OWFa) 1.0 non-assert and the patent commitment of the Community Specification License 1.0, closes that gap. By submitting a contribution to this specification (for example, a pull request modifying this document), each contributor accepts and makes this covenant for every version of this specification that incorporates its contribution.

Each contributor to this specification irrevocably covenants, on behalf of itself and its successors and assigns, not to assert any Essential Claims against any party for making, having made, using, selling, offering for sale, importing, or distributing a Conformant Implementation of this specification. Essential Claims are patent claims owned or controlled by the contributor — now or in the future, including claims later acquired — that are necessarily infringed by implementing the required portions (those designated MUST, REQUIRED, or SHALL) of a version of this specification to which the contributor contributed. "Necessarily infringed" follows the W3C Patent Policy definition of Essential Claims: it applies only where no commercially reasonable non-infringing alternative exists, and it excludes claims covering implementation choices this specification leaves open and technologies this specification merely references rather than describes in detail. A Conformant Implementation is any implementation that conforms to those required portions — whether or not it derives from any contributor's code or specification text; where an implementation forms part of a larger work, this covenant extends only to the portions that so conform. This covenant is royalty-free and worldwide, and supplements — never limits — the rights granted under the Apache 2.0 License.

This covenant runs with the Essential Claims: it is intended to bind any future owner, assignee, or exclusive licensee that acquires the right to enforce them, and a contributor that transfers a patent containing Essential Claims satisfies this obligation by notifying the transferee of this covenant. The covenant is suspended with respect to any party that asserts a patent infringement claim alleging that a Conformant Implementation of this specification infringes that party's patents (excluding claims brought defensively in response to a prior such assertion by a contributor or any successor or assignee bound by this covenant), for as long as that assertion is maintained.


ZKC: User-controlled compliance for privacy-preserving systems.