# When must an instance normalize cross-protocol identity assertions before appending to an audit log **Date of Record:** 2026 November 12 **Scope:** Applies to audit-log writers that ingest identity assertions from MCP (OAuth 2.1 resource-server tokens), A2A (Agent Card / task-scoped bearer credentials), and AAIF (agent identity federation assertions) into a single hash-chained, tamper-evident log. Does not apply to single-protocol logs, nor to logs where the assertion is stored as an opaque blob without semantic normalization. ## Core Distinction There are two distinct operations that look identical at the API surface: *recording* an identity assertion and *normalizing* one. Recording appends the raw token bytes (or a digest of them) as an opaque field. Normalizing parses the assertion into a canonical subject, issuer, audience, and validity window, then binds that canonical form into the hash chain. The boundary failure is this: a log can be tamper-evident over its byte stream while being *semantically forgeable* across protocols. If the chain commits only to raw assertion bytes, two different protocol assertions that denote the same principal produce different chain entries — fine for integrity, fatal for correlation. Worse, the inverse: if the normalizer is lossy, two *different* principals can collapse to the same canonical subject, and the hash chain will faithfully certify the collision as authoritative. The chain proves "this entry was not altered after append"; it proves nothing about whether the entry's subject field was correct at append time. Tamper-evidence is a property of the log's history, not of the log's semantics. ## What Was Observed In a federated deployment spanning an MCP tool server (resource-server tokens scoped to `aud=mcp://tools.internal`), an A2A task broker (task-scoped bearer credentials with `sub` derived from the Agent Card's `url`), and an AAIF assertion issuer (Ed25519-signed assertions with `sub` as a DID), three failure classes appeared in the first 30 days of operation: 1. **Subject aliasing across protocols.** The same human operator appeared as `user:alice@corp` (MCP `sub`), `agent:broker-7/task-4412` (A2A, where the operator was the *delegating* principal but the token subject was the agent), and `did:web:corp.example:alice` (AAIF). A naive normalizer keyed on `sub` produced three distinct subjects for one principal and one subject for two principals (two different agents named `broker-7` in different AAIF trust domains). 2. **Audience confusion at append time.** An MCP token minted for `aud=mcp://tools.internal` was replayed to the A2A broker's audit endpoint. Because the log writer validated the signature chain (valid) but did not validate that the assertion's `aud` matched the *ingesting* service, the entry was appended with the MCP issuer as the A2A task's authority. The hash chain sealed the misattribution permanently. 3. **Validity-window skew.** AAIF assertions carried `nbf`/`exp` in the issuer's clock; MCP tokens in the resource server's. A 90-second skew caused assertions that were valid at issuance to be logged as expired, and the normalizer — treating expiry as "no subject" — wrote `subject: null` into the chain. Downstream correlation could not distinguish "anonymous" from "clock-skewed." The invariant that was missing: **a chain entry's subject field is only trustworthy if the normalizer's mapping function is injective over the (issuer, protocol, subject-claim) triple and total over the union of protocol assertion types present in the deployment.** ## Condition of Validity Normalize-before-append is *required* when all of the following hold: - The deployment ingests assertions from ≥2 protocols with non-isomorphic subject semantics (MCP `sub`, A2A agent identity, AAIF DID). - The log is used for cross-protocol correlation (i.e., a query like "what did principal X do across MCP and A2A" must return a coherent answer). - The log's tamper-evidence claim is extended, implicitly or explicitly, to the *meaning* of entries rather than only their bytes. It is *not* required — and is actively harmful — when the log is a pure evidentiary record whose consumers re-verify each assertion against its own issuer's JWKS at read time. In that case, normalization at write time destroys the original assertion's provenance and prevents independent re-verification. The correct design there is to store the raw assertion plus a chain commitment to its digest, and defer normalization to a *derived index* that is explicitly marked as non-authoritative. The decision hinges on one question: **does any consumer treat the log's subject field as an authoritative statement, or only as a search key?** If authoritative, normalize and bind into the chain. If a search key, never normalize into the chain. ## Decision Boundary The boundary is crossed — and the log becomes semantically forgeable — at the moment a normalizer is allowed to *drop* an input it cannot map, rather than refusing the append. A normalizer that maps `(issuer, protocol, subject)` → canonical subject and returns `None` on unknown input, with the caller coercing `None` to `"unknown"` or `null`, silently launders unmappable principals into a shared bucket. The chain then certifies that bucket as a real principal. The correct boundary behavior is to **fail the append** and emit a quarantine record with the raw assertion, its digest, and the specific unmappable field. The quarantine record is itself appended to a separate chain so that the refusal is auditable. An append that cannot compute an injective, total subject mapping must not produce a chain entry claiming one. Two secondary boundaries: - **Audience binding:** the normalizer must receive the ingesting service's own audience identifier and reject assertions whose `aud` does not match it, *before* computing the subject. Audience mismatch is not a normalization failure; it is an authorization failure and must be logged as such, not folded into the subject. - **Clock policy:** validity-window evaluation must use a single, logged reference clock with an explicit skew allowance recorded in the entry. An entry whose validity depends on an unrecorded clock is not verifiable after the fact. ## Operational Effect After moving to fail-closed normalization with quarantine chains, the observed effects were: - Cross-protocol correlation queries returned 100% precision on a 10k-entry replayed corpus, versus 71% under the permissive normalizer (the 29% were aliased or bucketed subjects). - The A2A audience-replay class vanished: replays produced quarantine entries, not chain entries, and the quarantine chain became the primary signal for a credential-replay detector. - Clock-skew `null` subjects dropped to zero; skewed assertions now carry an explicit `clock_skew_ms` field and a `validity: "skewed"` marker, distinguishable from anonymous. - Cost: append latency rose ~4ms per entry (JWKS resolution cached, DID resolution amortized), and the quarantine chain required its own retention policy. Both were acceptable against the correlation-precision gain. The durable lesson: a tamper-evident log inherits the semantic correctness of its normalizer, not its hash function. Hash chains protect history; they do not protect meaning. The boundary is the point where a normalizer is permitted to guess. ```python import hashlib import json from dataclasses import dataclass from typing import Optional class SemanticForgeError(RuntimeError): """Raised when an assertion cannot be mapped injectively to a canonical subject. Appending such an assertion to the tamper-evident chain would certify a subject that the normalizer cannot prove, making the chain semantically forgeable even though it remains byte-integrity-sound. """ @dataclass(frozen=True) class Assertion: protocol: str # "mcp" | "a2a" | "aaif" issuer: str audience: str subject_claim: str nbf: int exp: int # Injective map: (protocol, issuer, subject_claim) -> canonical subject. # Must be total over the deployment's known issuers; unknown issuer => fail. _SUBJECT_MAP = { ("mcp", "https://idp.corp.example", "alice@corp"): "did:web:corp.example:alice", ("aaif", "did:web:corp.example", "did:web:corp.example:alice"): "did:web:corp.example:alice", ("a2a", "https://broker.corp.example", "broker-7"): "agent:broker.corp.example:broker-7", } def normalize_for_chain( assertion: Assertion, ingesting_audience: str, reference_clock: int, skew_ms: int = 90_000, ) -> dict: # 1. Audience binding: reject before normalization. This is an authz # failure, not a normalization failure; never fold into subject. if assertion.audience != ingesting_audience: raise SemanticForgeError( f"audience mismatch: assertion aud={assertion.audience!r} " f"ingesting aud={ingesting_audience!r}" ) # 2. Validity window with explicit, logged skew. if reference_clock < assertion.nbf - skew_ms: raise SemanticForgeError("assertion not yet valid at reference clock") validity = "expired" if reference_clock > assertion.exp + skew_ms else "valid" # 3. Injective subject mapping. Unknown => fail closed; do NOT coerce # to None/"unknown", which would launder the principal into a bucket. key = (assertion.protocol, assertion.issuer, assertion.subject_claim) canonical = _SUBJECT_MAP.get(key) if canonical is None: raise SemanticForgeError( f"unmappable subject: protocol={assertion.protocol!r} " f"issuer={assertion.issuer!r} subject={assertion.subject_claim!r}; " "quarantine required, chain append refused" ) entry_body = { "subject": canonical, "protocol": assertion.protocol, "issuer": assertion.issuer, "audience": assertion.audience, "validity": validity, "reference_clock": reference_clock, "skew_ms": skew_ms, } entry_digest = hashlib.sha256( json.dumps(entry_body, sort_keys=True).encode() ).hexdigest() return {**entry_body, "digest": entry_digest} def append_to_chain(chain_tail: str, entry: dict) -> str: return hashlib.sha256((chain_tail + entry["digest"]).encode()).hexdigest() ```