Status: NORMATIVE
Version: 0.10.10
Wire Format: interaction-record+jwt (Wire 0.2, current stable). peac-receipt/0.1 (Wire 0.1, frozen legacy).
This document defines the normative behavioral semantics for PEAC receipts. It MUST be read in conjunction with:
- PEAC-RECEIPT-SCHEMA-v0.1.json - Normative structural schema
- TEST_VECTORS.md - Normative conformance tests
- ERRORS.md - Normative error codes
Key words: The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals, as shown here.
Implementation requirement: Even if a JSON Schema validator does not fully support conditional constraints (if/then), a conformant PEAC implementation MUST enforce all behavioral rules defined in this document procedurally.
A ControlBlock consists of:
chain: Array ofControlStep(MUST be non-empty)decision: Final decision ("allow", "deny", or "review")combinator: Chain combinator logic (defaults to "any_can_veto")
Input: ControlBlock cb
Output: ControlValidationResult or PEACError
Algorithm:
1. Validate chain non-empty:
IF cb.chain.length == 0:
RETURN PEACError(
code: "E_INVALID_CONTROL_CHAIN",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/control/chain",
remediation: "Control chain MUST contain at least one step"
)
2. Default combinator:
IF cb.combinator is absent OR cb.combinator is null:
SET cb.combinator = "any_can_veto"
3. Validate combinator:
IF cb.combinator NOT IN ["any_can_veto"]:
RETURN PEACError(
code: "E_INVALID_CONTROL_CHAIN",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/control/combinator",
remediation: "Unknown combinator; v0.9 supports only 'any_can_veto'"
)
4. Validate each step:
FOR i = 0 TO cb.chain.length - 1:
step = cb.chain[i]
IF step.result NOT IN ["allow", "deny", "review"]:
RETURN PEACError(
code: "E_INVALID_CONTROL_CHAIN",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/control/chain/" + i + "/result",
remediation: "Step result MUST be 'allow', 'deny', or 'review'"
)
IF step.engine is empty OR NOT string:
RETURN PEACError(
code: "E_INVALID_CONTROL_CHAIN",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/control/chain/" + i + "/engine",
remediation: "Engine MUST be non-empty string"
)
5. Compute expected decision (any_can_veto semantics):
IF cb.combinator == "any_can_veto":
has_veto = false
FOR EACH step IN cb.chain:
IF step.result == "deny":
has_veto = true
BREAK
IF has_veto:
expected_decision = "deny"
ELSE:
expected_decision = "allow"
6. Validate decision consistency:
IF cb.decision != expected_decision:
RETURN PEACError(
code: "E_INVALID_CONTROL_CHAIN",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/control/decision",
remediation: "Decision '" + cb.decision + "' inconsistent with chain; expected '" + expected_decision + "' for any_can_veto"
)
7. RETURN ControlValidationResult(
valid: true,
decision: cb.decision
)
When combinator == "any_can_veto":
- If any step in the chain has
result == "deny", the finaldecisionMUST be"deny" - If all steps have
result == "allow", the finaldecisionMUST be"allow" - The
"review"result is reserved for future use; v0.9 implementations SHOULD treat it as requiring manual intervention
Rationale: This provides multi-party governance where any control engine can veto a transaction, similar to multi-sig or unanimous approval patterns.
ControlPurpose captures what the access is for. It maps external policy dialects (RSL, Content Signals, ai.txt) to a normalized purpose for receipts.
Well-known purposes:
| Purpose | Description |
|---|---|
crawl |
Web crawling/scraping |
index |
Search engine indexing |
train |
AI/ML model training |
inference |
AI/ML inference/generation |
ai_input |
RAG/grounding (using content as input to AI) [v0.9.17+] |
ai_index |
AI-powered search/indexing [v0.9.18+, RSL 1.0 alignment] |
search |
Traditional search indexing [v0.9.17+] |
RSL (Robots Specification Layer) 1.0 usage tokens can be mapped to PEAC ControlPurpose values using the @peac/mappings-rsl package.
RSL 1.0 Specification: rslstandard.org/rsl
Mapping Table:
| RSL Token | CAL ControlPurpose | Notes |
|---|---|---|
all |
['train', 'ai_input', 'ai_index', 'search'] |
All usage types |
ai-all |
['train', 'ai_input', 'ai_index'] |
All AI usage types |
ai-train |
['train'] |
AI/ML model training |
ai-input |
['ai_input'] |
RAG/grounding |
ai-index |
['ai_index'] |
AI-powered search/indexing |
search |
['search'] |
Traditional search indexing |
Note: RSL 1.0 uses ai-index, not ai-search. Previous versions of PEAC used ai_search which has been removed in v0.9.18.
Semantics:
- Mapping is many-to-many: RSL
ai-allandallexpand to multiple purposes - Unknown RSL tokens SHOULD log a warning but MUST NOT cause validation failure
- Reverse mapping is partial: CAL purposes without RSL equivalents (
crawl,index,inference) returnnull
Example:
Input: ["ai-train", "ai-input"]
Output: { purposes: ["train", "ai_input"], unknownTokens: [] }
Input: ["ai-all"]
Output: { purposes: ["train", "ai_input", "ai_index"], unknownTokens: [] }
Input: ["all"]
Output: { purposes: ["train", "ai_input", "ai_index", "search"], unknownTokens: [] }
Input: ["ai-train", "future-token"]
Output: { purposes: ["train"], unknownTokens: ["future-token"] }
A ControlBlock MUST be present in auth.control when:
- Payment present:
evidence.paymentis defined, OR - HTTP 402 enforcement:
auth.enforcement.method == "http-402", OR - Future protocols: Certain AP2/TAP/Agentic Commerce Protocol enforcement profiles (to be specified)
Algorithm: Control Requirement Check
Input: PEACEnvelope envelope
Output: boolean (control_required)
IF envelope.evidence.payment is present:
RETURN true
IF envelope.auth.enforcement is present AND envelope.auth.enforcement.method == "http-402":
RETURN true
RETURN false
When a receipt is validated:
IF ControlRequirementCheck(envelope) == true:
IF envelope.auth.control is absent:
RETURN PEACError(
code: "E_CONTROL_REQUIRED",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/control",
remediation: "Control block MUST be present when payment exists or enforcement.method is 'http-402'"
)
Control MAY be present even when not required, for example:
- Free-tier access policies
- Rate limiting without payment
- Audit trails for non-monetary operations
auth.iatMUST be a Unix timestamp in seconds (not milliseconds)auth.iatSHOULD be less than or equal to current time- Verifiers SHOULD allow ±60 seconds clock skew tolerance
auth.expMUST be a Unix timestamp in secondsauth.expMUST be greater than or equal toauth.iat- Verifiers MUST reject receipts where
current_time > auth.exp - Verifiers SHOULD allow ±60 seconds clock skew tolerance
Input: AuthContext auth, current_time (Unix seconds)
Output: boolean or PEACError
clock_skew = 60 // seconds
IF auth.exp is present:
IF auth.exp < auth.iat:
RETURN PEACError(
code: "E_INVALID_ENVELOPE",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/exp",
remediation: "Expiration (exp) MUST be >= issued at (iat)"
)
IF current_time > (auth.exp + clock_skew):
RETURN PEACError(
code: "E_EXPIRED_RECEIPT",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/exp",
remediation: "Receipt has expired; use a current receipt"
)
IF auth.iat > (current_time + clock_skew):
RETURN PEACError(
code: "E_INVALID_ENVELOPE",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/iat",
remediation: "Issued at (iat) is in the future"
)
RETURN true
The policy_hash field binds a receipt to a specific policy document using deterministic hashing.
Algorithm: Compute Policy Hash
Input: policy (JSON object or document)
Output: policy_hash (string)
1. Canonicalize policy using JCS (RFC 8785):
canonical_json = JCS(policy)
2. Compute SHA-256 digest:
digest = SHA256(canonical_json)
3. Encode as base64url (RFC 4648 Section 5):
policy_hash = base64url(digest)
RETURN policy_hash
Requirements:
- JCS canonicalization MUST follow RFC 8785 exactly
- SHA-256 MUST produce 256-bit (32-byte) digest
- base64url encoding MUST use URL-safe alphabet without padding
Verifiers MUST:
- Fetch policy from
auth.policy_uri - Apply SSRF protections (see Section 6)
- Parse fetched content as JSON
- Compute
policy_hashfrom fetched policy - Compare with
auth.policy_hash
Algorithm: Verify Policy Binding
Input: auth.policy_uri, auth.policy_hash
Output: policy (JSON) or PEACError
1. Fetch policy:
policy_content = SecureFetch(auth.policy_uri) // See Section 6
2. Parse as JSON:
TRY:
policy = JSON.parse(policy_content)
CATCH parse_error:
RETURN PEACError(
code: "E_POLICY_FETCH_FAILED",
category: "infrastructure",
severity: "error",
retryable: true,
remediation: "Policy document is not valid JSON"
)
3. Compute hash:
computed_hash = ComputePolicyHash(policy)
4. Verify:
IF computed_hash != auth.policy_hash:
RETURN PEACError(
code: "E_INVALID_POLICY_HASH",
category: "validation",
severity: "error",
retryable: false,
pointer: "/auth/policy_hash",
remediation: "Policy hash does not match policy content; expected " + computed_hash
)
5. RETURN policy
Status: NORMATIVE
All URL fetches (policy_uri, JWKS URIs, etc.) MUST implement SSRF protection to prevent attackers from using verifiers as proxies to internal networks or metadata endpoints.
REQUIRED:
- MUST accept:
https:// - MAY accept:
http://ONLY for localhost/127.0.0.1 in development/test environments - MUST reject:
file://,ftp://,gopher://,data://, and all other schemes
Verifiers MUST block requests to:
Private IPv4 ranges:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 127.0.0.0/8 (loopback, except localhost in dev)
Link-local:
- 169.254.0.0/16 (IPv4)
- fe80::/10 (IPv6)
Metadata endpoints:
- 169.254.169.254 (AWS, GCP, Azure metadata)
- fd00::/8 (IPv6 unique local)
Private IPv6:
- fc00::/7 (unique local addresses)
- ::1 (loopback)
Algorithm: Secure Fetch with SSRF Protection
Input: url (string)
Output: content (string) or PEACError
1. Parse URL:
TRY:
parsed = URL.parse(url)
CATCH parse_error:
RETURN PEACError(
code: "E_INVALID_ENVELOPE",
category: "validation",
remediation: "Invalid URL format"
)
2. Validate scheme:
IF parsed.scheme == "http":
IF parsed.hostname NOT IN ["localhost", "127.0.0.1", "::1"]:
RETURN PEACError(
code: "E_SSRF_BLOCKED",
category: "verification",
severity: "error",
retryable: false,
remediation: "HTTP URLs only allowed for localhost; use HTTPS"
)
ELSE IF parsed.scheme != "https":
RETURN PEACError(
code: "E_SSRF_BLOCKED",
category: "verification",
remediation: "Only HTTPS URLs allowed"
)
3. Resolve hostname to IP:
ip_addresses = DNS.resolve(parsed.hostname)
4. Check each resolved IP:
FOR EACH ip IN ip_addresses:
IF ip IN blocked_ip_ranges: // See Section 6.2
RETURN PEACError(
code: "E_SSRF_BLOCKED",
category: "verification",
severity: "error",
retryable: false,
remediation: "SSRF protection blocked request to private/metadata IP: " + ip,
details: {
blocked_ip: ip,
hostname: parsed.hostname
}
)
5. Fetch with timeout:
TRY:
content = HTTP.get(url, {
connect_timeout: 5000, // 5 seconds
total_timeout: 10000, // 10 seconds
follow_redirects: false // Do not follow redirects automatically
})
CATCH network_error:
RETURN PEACError(
code: "E_POLICY_FETCH_FAILED", // or E_JWKS_FETCH_FAILED
category: "infrastructure",
severity: "error",
retryable: true,
remediation: "Network error fetching resource"
)
6. RETURN content
Verifiers SHOULD cache fetched policies and JWKS to reduce network requests and SSRF exposure:
- Cache key:
policy_hashfor policies,issuerfor JWKS - Cache TTL: Respect
Cache-Controlheaders, maximum 3600 seconds (1 hour) - Cache MUST be invalidated if fetch fails with SSRF error
When auth.binding.method == "dpop", verifiers MUST validate the DPoP proof of possession.
DPoP proof is a JWT sent in the DPoP HTTP header:
Required claims:
typ: MUST be"dpop+jwt"alg: Signing algorithm (e.g.,ES256,EdDSA)jwk: Public key (JWK format)jkt: Key thumbprint (SHA-256 hash of JWK, base64url-encoded)iat: Issued at (Unix seconds)htm: HTTP method (POST, GET, etc.)htu: HTTP URI (without query string or fragment)nonce: Server-issued nonce (if server requires nonce)
Input: dpop_jwt (string), http_method (string), http_uri (string), server_nonce (string or null)
Output: boolean or PEACError
1. Parse DPoP JWT:
TRY:
header = JWT.decode_header(dpop_jwt)
claims = JWT.decode_payload(dpop_jwt) // Do NOT verify signature yet
CATCH parse_error:
RETURN PEACError(
code: "E_DPOP_INVALID",
category: "verification",
severity: "error",
retryable: false,
remediation: "DPoP proof is not a valid JWT"
)
2. Validate header:
IF header.typ != "dpop+jwt":
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP JWT MUST have typ='dpop+jwt'"
)
3. Extract public key:
public_key = header.jwk
4. Verify signature:
TRY:
JWT.verify(dpop_jwt, public_key, header.alg)
CATCH verification_error:
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP signature verification failed"
)
5. Validate jkt (key thumbprint):
computed_jkt = base64url(SHA256(JCS(public_key)))
IF claims.jkt != computed_jkt:
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP jkt does not match public key thumbprint",
details: {
expected_jkt: computed_jkt,
provided_jkt: claims.jkt
}
)
6. Validate iat (issued at):
current_time = UnixTime()
IF claims.iat < (current_time - 60) OR claims.iat > (current_time + 60):
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP iat is outside acceptable window (±60 seconds)"
)
7. Validate htm (HTTP method):
IF claims.htm != http_method:
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP htm '" + claims.htm + "' does not match request method '" + http_method + "'"
)
8. Validate htu (HTTP URI):
normalized_uri = NormalizeURI(http_uri) // Remove query and fragment
IF claims.htu != normalized_uri:
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP htu does not match request URI"
)
9. Validate nonce (if required):
IF server_nonce is not null:
IF claims.nonce != server_nonce:
RETURN PEACError(
code: "E_DPOP_INVALID",
remediation: "DPoP nonce does not match server-issued nonce"
)
10. Check nonce replay (L3 or L4):
IF NonceAlreadyUsed(claims.nonce):
RETURN PEACError(
code: "E_DPOP_REPLAY",
category: "verification",
severity: "error",
retryable: false,
remediation: "DPoP nonce has already been used"
)
RecordNonce(claims.nonce, ttl=60) // 60 second TTL
11. RETURN true
L3 (Single-Node):
- Maintain in-memory cache of used nonces
- Use LRU eviction or TTL-based expiration (60 seconds)
- Suitable for single-instance verifiers
L4 (Distributed):
- Use distributed cache (Redis, Memcached) with TTL
- Partition by verifier cluster or shard by nonce hash
- Suitable for horizontally-scaled verifiers
Nonce format:
- Server generates: Random 128-bit value, base64url-encoded
- Issued via:
WWW-Authenticate: DPoP error="use_dpop_nonce", error_description="..." - TTL: 60 seconds
Status: NORMATIVE (v0.9.18+)
When auth.binding.method == "http-signature", verifiers MUST validate HTTP Message Signatures per RFC 9421.
PEAC surfaces (e.g., Cloudflare Worker, Next.js middleware) implement signature verification for agent protocols like Visa TAP (Trusted Agent Protocol).
Required Parameters:
@method: HTTP method@authority: Request host@request-target: Request path + querycontent-digest: SHA-256 digest of request body (if present)content-length: Request body length (if present)content-type: Request content type (if present)
TAP-Specific Tags (covered components):
| Tag | Components Covered |
|---|---|
peac-all |
@method, @authority, @request-target, body headers (if present) |
peac-min |
@method, @authority only |
Verification Algorithm:
Input: request, signature_input (Signature-Input header), signature (Signature header)
Output: boolean or PEACError
1. Parse Signature-Input header:
TRY:
params = parse_structured_field(signature_input)
CATCH parse_error:
RETURN PEACError(
code: "E_TAP_SIGNATURE_INPUT_MALFORMED",
category: "validation",
severity: "error"
)
2. Extract keyid from params:
keyid = params.keyid
IF keyid is absent:
RETURN PEACError(code: "E_TAP_KEY_NOT_FOUND")
3. Verify temporal validity:
created = params.created
expires = params.expires
// TAP hard limit: 8-minute window
IF (expires - created) > 480:
RETURN PEACError(code: "E_TAP_WINDOW_TOO_LARGE")
current_time = UnixTime()
clock_skew = 60 // seconds
IF current_time < (created - clock_skew):
RETURN PEACError(code: "E_TAP_TIME_INVALID")
IF current_time > (expires + clock_skew):
RETURN PEACError(code: "E_TAP_TIME_INVALID")
4. Fetch public key via JWKS:
key = JWKS.fetch(issuer, keyid)
IF key is null:
RETURN PEACError(code: "E_TAP_KEY_NOT_FOUND")
5. Construct signature base:
base = construct_signature_base(request, params.components)
6. Verify Ed25519 signature:
valid = Ed25519.verify(base, signature, key)
IF NOT valid:
RETURN PEACError(code: "E_TAP_SIGNATURE_INVALID")
7. Check replay (if nonce present):
IF params.nonce is present:
IF NonceAlreadyUsed(issuer, keyid, params.nonce):
RETURN PEACError(code: "E_TAP_REPLAY_DETECTED")
RecordNonce(issuer, keyid, params.nonce, ttl=480)
8. RETURN true
Fail-Closed Semantics:
- Unknown TAP tags MUST be rejected (no silent acceptance)
- Missing issuer allowlist MUST reject all requests (configuration required)
- Replay protection MUST be enforced when nonce is present
Reference Implementations:
@peac/http-signatures: RFC 9421 signature creation/verification@peac/worker-cloudflare: Cloudflare Worker TAP verifier@peac/middleware-nextjs: Next.js Edge middleware TAP verifier
The sub claim MUST identify an agent, client, or service account, NOT a human user.
Requirements:
subMUST identify automated agents, service accounts, or applicationssubSHOULD NOT contain human personally identifiable information (PII)subSHOULD use stable, pseudonymous identifiers
Examples:
- COMPLIANT:
"agent:example-researcher-v1" - COMPLIANT:
"service:payment-processor-prod-us-west" - COMPLIANT:
"client:mobile-app-installation-abc123" - NON-COMPLIANT:
"user:john.doe@example.com"(human email) - NON-COMPLIANT:
"customer:+1-555-1234"(phone number) - NON-COMPLIANT:
"patient:SSN-123-45-6789"(government ID)
Rationale:
- PEAC receipts are designed for agent-to-agent interactions in automated systems
- Receipts may be logged, archived, shared for audit, or transmitted across organizational boundaries
- Avoiding PII simplifies compliance with GDPR, CCPA, HIPAA, and similar regulations
Implementers SHOULD avoid including personal data anywhere in PEAC receipts.
If personal data is necessary (strongly discouraged):
- Use
meta.redactionsto mark fields that can be redacted - Use
meta.privacy_budget.k_anonymityfor aggregation hints - Document data handling in implementation-specific privacy policies
- Consult legal counsel for regulatory compliance
Vendor-specific or implementation-specific data MUST NOT appear in normative top-level fields.
Allowed locations:
evidence.payment.evidence- Payment rail-specific detailsevidence.extra- Protocol-specific evidencemeta.debug- Non-normative debugging information
Prohibited locations:
- Top-level fields in
auth,evidence,meta - Top-level fields in
evidence.payment(use nestedevidenceinstead) - Error codes or error messages (vendor details go in
error.details)
SubjectProfile and SubjectProfileSnapshot are OPTIONAL catalogue structures for identifying actors (human, org, or agent) in PEAC interactions. Implementations MAY omit subject profiles entirely for anonymous access, purely technical subjects, or when identity context is not relevant to policy evaluation.
Design Philosophy:
SubjectProfile is intentionally minimal. It provides an identity hook, NOT a comprehensive identity record. Detailed identity attributes belong in external identity providers (IdPs), directories, or IAM systems.
Requirements:
idMUST be a stable, unique identifier but SHOULD NOT contain PII directlytypeclassifies the subject (human, org, agent) for policy purposeslabelsif present SHOULD NOT contain PII; use abstract tags like["premium", "verified"]metadataSHOULD NOT store sensitive PII; prefer opaque references to external systems
Privacy Guidance:
-
Use opaque identifiers: Prefer
"user:abc123"over"user:john.doe@example.com" -
Delegate to external IdPs: Store detailed identity attributes in your IdP/directory system and reference them by opaque ID in PEAC profiles
-
Minimize captured_at granularity:
SubjectProfileSnapshot.captured_atrecords when the profile was observed; log only what is needed for audit -
Avoid metadata bloat: The
metadatafield is for application-specific attributes, not PII storage; if you must store identity claims, encrypt or hash them
Examples:
- COMPLIANT:
{ "id": "agent:crawler-v2", "type": "agent", "labels": ["indexer"] } - COMPLIANT:
{ "id": "org:acme-12345", "type": "org" } - NON-COMPLIANT:
{ "id": "user:john.doe@example.com", "type": "human", "metadata": { "ssn": "123-45-6789" } }
Rationale:
Subject profiles may appear in receipts that are logged, archived, or shared across organizational boundaries. Keeping profiles minimal and PII-free simplifies regulatory compliance (GDPR, CCPA, HIPAA) and reduces data breach impact.
Compliance Documentation:
Implementations MUST document their retention and minimization policies for SubjectProfileSnapshot logs as part of their own compliance program. This documentation SHOULD specify retention periods, access controls, and deletion procedures.
SubjectProfileSnapshot MAY be included in PEAC envelopes to capture identity context at the point of receipt issuance. This enables policy evaluation and audit trails without modifying the signed JWS payload.
Placement:
subject_snapshot is placed at auth.subject_snapshot in the PEACEnvelope structure:
interface AuthContext {
iss: string;
aud: string;
sub: string;
iat: number;
exp?: number;
rid: string;
policy_hash: string;
policy_uri: string;
// ... other auth fields ...
subject_snapshot?: SubjectProfileSnapshot; // v0.9.17+
}Rationale for auth-level placement:
- Consistent with envelope structure (
{ auth, evidence?, meta? }) - Auth-related fields stay in the auth block
- Avoids top-level key sprawl
- Not inside JWS claims (keeps cryptographic surface small)
Validation Behavior:
-
If
subject_snapshotis ABSENT:- Behave exactly as before
- No validation failure
- Receipt issuance and verification proceed normally
-
If
subject_snapshotis PRESENT:- Schema validation: Validate against
SubjectProfileSnapshotSchema - Privacy check (advisory): Log warning if
idlooks like PII (email/phone) - No external lookups: Do not fetch from IdP or directory
- Pass-through: Include in envelope, return in verify response
- Schema validation: Validate against
Security Note:
subject_snapshot is envelope metadata outside JWS claims. It is NOT automatically tamper-evident. The existing auth.sub (opaque ID) provides the cryptographic binding if needed. If strong binding is required in the future, options include adding a subject_snapshot_hash field to JWS claims or including in an envelope-level binding mechanism.
Wire Format:
Wire format peac-receipt/0.1 is unchanged. subject_snapshot is envelope-level metadata, not part of the signed JWS payload.
CRITICAL: Implementations MUST enforce all behavioral rules defined in this document, even if the JSON Schema validator does not fully support conditional constraints (if/then).
Minimum validation steps:
- Structural validation: Validate against JSON Schema
- Control chain validation: Run algorithm from Section 2.2
- Control requirements: Check Section 3.1 invariants
- Temporal validity: Check Section 4.3
- Policy binding: Verify Section 5.2 (if policy verification required)
- SSRF protection: Apply Section 6 when fetching URLs
- DPoP verification: Run algorithm from Section 7.2 (if DPoP binding present)
Implementations MAY skip certain validations (e.g., policy fetch) if operating in "envelope-only" mode, but MUST document which validations are performed vs. deferred.
This specification (v0.9) defines minimal semantics for initial deployment. Future versions may add:
- Additional combinators (
all_must_allow,majority,unanimous) - Multi-payment semantics (
evidence.payments[]) - Receipt chaining (
parent_rid,supersedes_rid,delegation_chain) - Policy schema (currently informational)
- Additional transport bindings (HTTP Message Signatures, etc.)
Reserved fields (e.g., payments[], chaining fields) are present in the schema but have no normative semantics in v0.9.x. Implementations MUST NOT rely on reserved fields for correctness.
An implementation is conformant with PEAC v0.9 if it:
- Validates receipts according to PEAC-RECEIPT-SCHEMA-v0.1.json
- Enforces all behavioral rules in this document
- Passes all normative test vectors in TEST_VECTORS.md
- Returns errors using codes from ERRORS.md
This section defines the normative behavior for PEAC HTTP headers.
PEAC uses the following HTTP headers. All names follow RFC 6648 (no X- prefix for new headers).
| Header | Direction | Description |
|---|---|---|
PEAC-Receipt |
Response | JWS-encoded receipt |
PEAC-Purpose |
Request | Declared purpose(s) of access |
PEAC-Purpose-Applied |
Response | Purpose enforced by server |
PEAC-Purpose-Reason |
Response | Reason for enforcement decision |
DPoP |
Request | Proof of possession token (RFC 9449) |
Canonical source: specs/kernel/constants.json
The PEAC-Purpose header declares the requester's intended use of the resource.
Format:
PEAC-Purpose: train
PEAC-Purpose: train, search
PEAC-Purpose: user_action
Parsing Rules (MUST):
- Split on commas (
,) - Trim optional whitespace (OWS) around tokens
- Lowercase all tokens
- Drop empty tokens
- Deduplicate (preserve first occurrence)
- Preserve input order (no sorting)
- Preserve unknown tokens (normalized) - never reject by default
Algorithm:
Input: header_value (string)
Output: purposes (string[])
1. Split header_value by ','
2. For each token:
a. Trim leading/trailing whitespace
b. Convert to lowercase
c. If empty after trim, skip
3. Deduplicate (keep first occurrence)
4. Return array in input order
Canonical Purpose Tokens:
| Token | Description |
|---|---|
train |
Model training data collection |
search |
Traditional search indexing |
user_action |
Agent acting on user behalf |
inference |
Runtime inference / RAG |
index |
Content indexing (store) |
Internal-Only Tokens (never valid on wire):
| Token | Description |
|---|---|
undeclared |
Applied when header missing/empty |
Extension Tokens:
- Format:
namespace:purpose(lowercase, colon-separated) - Examples:
cf:ai_crawler,vendor:custom_purpose - MUST be preserved and forwarded even if unknown
When PEAC-Purpose header is missing or empty:
- Internal state:
purpose_declared: [](empty array) - Receipt claim:
purpose_reason: "undeclared_default" - Server applies default policy based on profile
When PEAC-Purpose: undeclared is explicitly sent:
- 400 Bad Request -
undeclaredis not a valid wire token - Rationale: Prevents gaming by explicitly declaring "undeclared"
When multiple purposes are declared:
PEAC-Purpose: train, search
Server Behavior:
- Choose ONE
purpose_enforcedfor the access decision - MUST be one of the declared known purposes, OR
- A more restrictive known purpose (downgrade)
Receipt Claims:
purpose_declared: Array of all declared purposes (e.g.,["train", "search"])purpose_enforced: Single token enforced (e.g.,"train")purpose_reason: Enum explaining enforcement (e.g.,"allowed")
Unknown tokens (not in canonical list, not registered extensions):
- MUST be preserved and forwarded (for forward-compatibility)
- MAY be recorded in receipt metadata or logs
- MUST NOT affect policy evaluation unless explicitly understood
- Implementations SHOULD warn on unknown tokens but MUST NOT reject
Security Rationale: Unknown tokens cannot become an accidental bypass surface.
The PEAC-Purpose-Applied header indicates which purpose was enforced.
Format:
PEAC-Purpose-Applied: train
- Single token (matches
purpose_enforcedin receipt) - MUST be present when
PEAC-Purposewas sent
The PEAC-Purpose-Reason header explains why the enforced purpose differs from declared.
Format:
PEAC-Purpose-Reason: allowed
PEAC-Purpose-Reason: undeclared_default
Reason Values:
| Value | Description |
|---|---|
allowed |
Purpose permitted as declared (happy path) |
constrained |
Allowed with rate limits applied |
denied |
Purpose rejected by policy |
downgraded |
More restrictive purpose applied |
undeclared_default |
No purpose declared, default applied |
unknown_preserved |
Unknown purpose token, preserved but flagged |
If response behavior varies by PEAC-Purpose:
- Server MUST emit
Vary: PEAC-Purpose - Caching layers MUST respect this header for correct cache invalidation
Example:
HTTP/1.1 200 OK
PEAC-Purpose-Applied: train
PEAC-Purpose-Reason: allowed
Vary: PEAC-PurposeThese limits are RECOMMENDED for implementation safety, not wire constraints:
| Limit | Value | Rationale |
|---|---|---|
| Max tokens per header | 8 | Prevent abuse |
| Max chars per token | 48 | Reasonable namespace:purpose length |
Implementations SHOULD warn when limits are exceeded but MAY accept larger values.
-
v0.9.18: TAP + HTTP Message Signatures + Schema Normalization
- HTTP Message Signatures (RFC 9421): Section 7.4 normative verification algorithm
- TAP (Trusted Agent Protocol) support via
@peac/mappings-tap - Reference surfaces:
@peac/worker-cloudflare,@peac/middleware-nextjs - Schema normalization:
toCoreClaims()for cross-mapping parity (see SCHEMA-NORMALIZATION.md) - RSL 1.0 alignment:
ai-indextoken (notai-search),alltoken support - Canonical flow examples in
examples/directory
-
v0.9.17: RSL Alignment + Subject Binding
- Extended
ControlPurposewith RSL usage tokens:ai_input,search - Added
@peac/mappings-rslpackage for RSL to CAL mapping - Lenient handling: unknown RSL tokens log warning but do not cause validation failure
- Subject Binding: Optional
subject_snapshotonauthblock for identity context at issuance - Section 8.5 documents placement, validation behavior, and security considerations
- Extended
-
v0.9.16 (2025-12-07): Control Abstraction Layer (CAL) semantics, PaymentEvidence extensions, Subject Profile Catalogue
- CAL:
ControlPurpose(crawl, index, train, inference),ControlLicensingMode(subscription, pay_per_crawl, pay_per_inference), any_can_veto combinator lattice - PaymentEvidence:
aggregatorfield for marketplace/platform identifiers,splits[]array for multi-party payment allocation with invariants (party required, amount or share required) - Subject Profile:
SubjectProfileandSubjectProfileSnapshotoptional catalogues for actor identity (human, org, agent); Section 8.4 privacy guidance
- CAL:
-
v0.9.15 (2025-01-18): Initial normative behavior specification with control chain, SSRF protection, DPoP verification, and privacy constraints