# IntelligencePro verification recipe

**Audience**: relying parties consuming IntelligencePro attestations who need to
verify the cryptographic chain without trusting the platform.
**Scope**: full chain — DID + JWKS resolution → W3C VC layer →
judge attestation → HMAC manifest layer (where present).

This recipe was published per the cycle-362 supply-chain audit
P1-7 to make the multi-layer signing posture verifiable
end-to-end and to make the trust boundaries between layers
explicit.

## Layer overview

IntelligencePro signs at THREE distinct layers, each with a different key
shape and a different verification recipe. A relying party who
wants full chain-of-custody MUST verify all three; partial
verification has documented blind spots called out below.

| Layer | Signer | Algorithm | Where it lives | Spec status |
|---|---|---|---|---|
| L1 W3C VC envelope | Platform domain key | Ed25519 | `proof[0]` on every issued credential | W3C VC 2.0 + DataIntegrityProof (eddsa-jcs-2022) conformant |
| L2 Judge attestation | Per-judge `did:key` | Ed25519 | `proof[1]` (type `IntelligencePro.JudgmentProof`) on judgment VCs | IntelligencePro-proprietary, see note |
| L3 Manifest chain | Platform attestation-chain Ed25519 key (Phase B cycle 627+) WHEN PROVISIONED; else HMAC-only | Ed25519 when the chain key is provisioned; otherwise HMAC-SHA256 (platform-internal) | `chainSignatureEd25519` + `chainSignature` when provisioned; `chainSignature` (HMAC) only otherwise | **Independently verifiable ONLY when the attestation-chain Ed25519 key is provisioned** (`chainSignatureEd25519` present + `ip-attestation-chain-*` JWK in `/.well-known/jwks.json`). On deployments without it (the live default — sca-944-P0-1) L3 is HMAC-only / platform-attested, NOT independently consumer-verifiable; re-verify via `POST /api/knowledge/attestation/verify` (`verifiedVia`). See the §L3 status banner below. |

## Conformance posture (read this before §L1)

**IntelligencePro L1 credentials are NOT strict W3C VC Data
Integrity conformant.**  We ship `eddsa-jcs-2022` with three
binding-format deviations from the W3C VC-DI specification
(documented in §L1 below), PLUS an issuer↔proof-signer
relationship gap (deviation #4, immediately below).  Stock
didkit / waltid / Sphereon / DigitalBazaar in strict mode WILL
reject our credentials.

**Deviation #4 — the credential `issuer` (the judge) does not
authorize the `proof[0]` signer (the platform).**  On a judgment
VC the `issuer` is the judge's `did:key:` — the logical attester
— and `proof[1]` carries that judge's own Ed25519 signature (see
§L2).  But the W3C-conformant `proof[0]`
(`DataIntegrityProof` / `eddsa-jcs-2022`) is signed by the
**platform** domain key (`did:web:ip.tekton.cc#ip-domain-…`),
which is NOT listed as an `assertionMethod` in the judge's
`did:key` document.  W3C VC-DI requires `proof.verificationMethod`
to be authorized by the credential `issuer`; here it is not, so a
strict verifier rejects on the issuer↔proof-controller
relationship independently of the three binding-format deviations
above.  **Do NOT read `issuer` as "the judge signed `proof[0]`"** —
`proof[0]` is a platform envelope signature (it proves the
credential was issued through IntelligencePro's pipeline, per §L1),
while the judge's own signature is `proof[1]`, verifiable against
the judge's published key (§L2).  Why it is shaped this way: until
Phase B client-side signing ships (§L2), the platform
counter-signs the W3C envelope on the judge's behalf.  The durable
fix is the same Phase B restructuring §L2 describes — re-issue the
judge attestation as a separate `did:key`-issued VC with its own
`eddsa-jcs-2022` proof, OR set `issuer` to the platform
`did:web:ip.tekton.cc` and represent the judge purely in
`credentialSubject`.  (Identified by the cycle-1010
regulatory-compliance audit, P0-1.)

For lower-stakes consumers (RAG pipelines, marketplace signal,
internal audit log) the deviations are reproducible binding-
format choices over the standard Ed25519 + RFC 8785 JCS
primitives — runnable verifiers are shipped at
[`/docs/l1-verifier-python.md`](l1-verifier-python.md) and
[`/docs/l1-verifier-node.md`](l1-verifier-node.md) in ~80 LoC
each, no transitive Sphereon/didkit deps required.

For higher-stakes consumers (SOC2, FedRAMP, eIDAS workloads, or
any policy that mandates strict W3C DI conformance):
**IntelligencePro does not currently meet the bar.**  A strict-DI
issuance mode is on the roadmap but not committed to a release.
Until then, the platform should not be the sole cryptographic
anchor for regulated workloads.  Use it as an additional signal
alongside a strict-conformant primary trust root.

R12 fresh-integrator probe (2026-05-23 06:30 UTC) reached PASS in
~6 minutes on the cycle-867 contract with both runnable verifiers
and reproduced all three documented failure modes independently
— so the disclosure is operationally honest, not aspirational.

## L1: W3C VC envelope (recommended floor for all consumers)

This is the layer that interoperates with stock tooling **modulo
the three deviations called out below**.  Verifying L1 confirms:

- The credential was issued through IntelligencePro's pipeline (the domain key signed it)
- The credential body wasn't tampered with on the wire
- The `created` timestamp, `verificationMethod`, `audience`, and
  `challenge` (cycles 368 + 372) are all bound to the signature
- The credential's status-list bit is `0` (not revoked, cycle 369)

What L1 does NOT prove:
- WHO the judges were that produced the underlying scores
- WHAT input bytes the judges saw (this is in the manifest layer)

### Recipe

1. Parse the credential. Extract `proof[0]` where
   `type=DataIntegrityProof` and `cryptosuite=eddsa-jcs-2022`.
2. Resolve `proof[0].verificationMethod` → fetch
   `https://ip.tekton.cc/.well-known/jwks.json` and find the JWK
   whose `kid` matches the fragment.
3. Reconstruct the signing input ("hashData") =
   `sha256(canon(proofConfig)) || sha256(canon(document))` — the
   concatenation of two 32-byte SHA-256 digests (64 bytes total),
   per the eddsa-jcs-2022 spec:
   - `proofConfig` = the proof object MINUS `proofValue`. **Note (cycle 865)**: the proofConfig INCLUDES `proofScope: "platform-issued"`. This is non-W3C-standard but is in the signed bytes — strip-only-proofValue is required, do NOT also strip proofScope. Cycle-866 verification probe confirmed Ed25519 verify FAILS when proofScope is excluded.
   - `document` = the credential MINUS the `proof` field
   - Canonicalization = RFC 8785 JCS over the safe envelope
     (cycle 374; ASCII-only strings, finite numbers in safe range)
   - **Binding form (cycle 916 — FIXED, supersedes the prior cycle-368
     raw-concat form)**: hashData = `sha256(jcs(proofConfig)) ||
     sha256(jcs(document))`. Pre-cycle-916 the platform signed the RAW
     concatenated JCS strings `jcs(proofConfig) || jcs(document)`
     (no hash step) — a non-conformant deviation that made stock W3C
     verifiers (didkit/waltid/Sphereon) reject every credential. Cycle
     916 switched the signer to the spec hashData; this recipe now
     matches the wire. Independently confirmed on 2026-05-23 by the
     supply-chain-verify probe: the sha256-concat form verifies TRUE,
     the old raw-concat form verifies FALSE.
   - **One remaining IP-specific variant**: `proofScope` stays IN
     proofConfig (not stripped) — see the cycle-865 note above. The
     `@context` is NOT inherited into proofConfig (JCS canonicalizes
     the proof-options object literally; unlike eddsa-rdfc-2022 there
     is no @context injection step).
4. Decode `proof[0].proofValue`, then Ed25519-verify it against the
   64-byte hashData using the resolved public key. **Encoding — do NOT
   assume hex (supply-chain audit cycle 1019 P2):** `proofValue` is W3C
   Data-Integrity multibase — a leading `z` followed by base58btc
   (Bitcoin alphabet) of the 64-byte raw Ed25519 signature. Strip the
   leading `z`, base58btc-decode → 64 raw bytes, then verify. This is a
   DIFFERENT encoding from §L2's `proof[1].signatureHex` (which is raw
   hex, 128 chars) — the two proofs on the same credential use different
   signature encodings; do not conflate them.
5. Check `validFrom` ≤ now ≤ `validUntil`.

   **Validity-window policy (cycle 1212 — verification persona F2):**
   `validUntil` on a judgment VC is a FIXED ~90-day window from `signedAt`
   (cycle 331), NOT tied to the brief's lifecycle. After it elapses the
   credential is EXPIRED, not revoked — re-fetch the credential endpoint
   for a freshly-stamped envelope (the underlying attestation is unchanged;
   only the validity window is re-issued). The status-list VC is likewise
   re-issued live with a fresh `created` (rounded to a 2-minute bucket) and
   re-signed on each fetch — two fetches can return different `proofValue`s
   yet each self-verifies TRUE (the signature covers its own `created`);
   the `encodedList` payload is stable. Treat expiry as a re-fetch trigger,
   liveness as the status list (step 6), and re-signed status VCs as
   expected, not a tamper signal.
6. Fetch `credentialStatus.statusListCredential`, verify ITS proof
   (recursive L1 step), decode the bitstring, check that the bit
   at `statusListIndex` is 0.

   **`credentialStatus` field shape** (Wave-4 JS-integrator
   probe 2026-05-23 surfaced this was undocumented):

   ```json
   "credentialStatus": {
     "id": "https://ip.tekton.cc/credentials/status/v1#<index>",
     "type": "BitstringStatusListEntry",
     "statusPurpose": "revocation",
     "statusListIndex": "<integer-as-string>",
     "statusListCredential": "https://ip.tekton.cc/credentials/status/v1"
   }
   ```

   This shape is the W3C BitstringStatusList v1.0 entry shape; the
   platform doesn't deviate.  Note `statusListIndex` is a STRING per
   spec (not a number) — common parser footgun.

   **Bitstring decode (the part the W3C spec hand-waves)**: the
   status-list VC carries a `credentialSubject.encodedList` field
   that begins with `H4sI...` — that's the gzip magic.  Per W3C
   VC-Status-List, `encodedList` is `base64url(gzip(bitstring))`,
   bit order MSB-first.  Runnable snippets:

   **Node.js**:
   ```javascript
   import { gunzipSync } from "node:zlib";

   function decodeBitstringStatusList(encodedList, index) {
     // base64url → bytes
     const b64 = encodedList.replace(/-/g, "+").replace(/_/g, "/");
     const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
     const gzipped = Buffer.from(padded, "base64");
     // gunzip → raw bitstring bytes
     const bits = gunzipSync(gzipped);
     // bit at `index` (MSB-first within each byte)
     const byte = bits[Math.floor(index / 8)];
     const bit = (byte >> (7 - (index % 8))) & 1;
     return bit;  // 0 = valid, 1 = revoked
   }
   ```

   **Python**:
   ```python
   import base64, gzip

   def decode_bitstring_status_list(encoded_list: str, index: int) -> int:
       # base64url -> bytes
       b64 = encoded_list + "=" * (-len(encoded_list) % 4)
       gzipped = base64.urlsafe_b64decode(b64)
       # gunzip -> raw bitstring bytes
       bits = gzip.decompress(gzipped)
       # bit at `index` (MSB-first within each byte)
       byte = bits[index // 8]
       return (byte >> (7 - (index % 8))) & 1  # 0=valid, 1=revoked
   ```

   No additional deps required (stdlib in both languages).

### Self-test against a live golden vector

Before you trust your verifier on a high-stakes credential, point it at
a VC whose expected outcome is known. The status-list VC at
**`https://ip.tekton.cc/credentials/status/v1`** is the one always-
fetchable L1 credential during cold-start (every other kind needs a
published judgment, and `proposals.summary` is 0 across kinds today —
see the §L1 corrigenda). The §L1 recipe above (steps 1–4) verifies
**TRUE** against it — confirmed end-to-end against the live deployment
2026-05-25 with a ~40-line stdlib-plus-Ed25519 implementation.

If your implementation returns FALSE on that VC, you've hit one of the
three IP-specific make-or-break variants — check these in order, they
are the empirically-confirmed failure modes:

1. **`proofScope` stripped** — it must STAY in `proofConfig` (cycle
   865). Stripping it (the intuitive "remove non-W3C fields" move) flips
   verify to FALSE.
2. **raw-concat instead of sha256-concat** — `hashData` is
   `sha256(jcs(proofConfig)) || sha256(jcs(document))` (64 bytes), NOT
   `jcs(proofConfig) || jcs(document)` (cycle 916). The old raw-concat
   form verifies FALSE on the current wire.
3. **`proofValue` decoded as hex** — it is multibase `z` + base58btc of
   the 64-byte signature (cycle 1019), NOT hex. Hex-decoding yields the
   wrong bytes and FALSE.

Get TRUE on this vector first; then run your verifier against
higher-stakes credentials with confidence the binding form is right.

### Golden REVOKED vector — prove a bit can flip to 1 (cycle 1212)

The always-valid self-test above proves your verifier reads `bit 0 =
not-revoked`. Its DUAL proves the revocation leg actually works: that the
served bitstring CAN carry a set bit and your `reject-on-revoked` path
fires. Without it, an all-zeros list is observationally indistinguishable
from a hardcoded zero stub that reports every credential valid forever
(a real relying-party doubt).

**Reserved golden index: `131072`.** Fetch the status-list VC at
`https://ip.tekton.cc/credentials/status/v1`, decode (the step-6 snippet),
and check the bit at index **131072** — it is permanently **`1`
(revoked)**. Index 131072 is exactly ONE PAST the assignable range: a real
credential's `statusListIndex = sha256(judgmentId) % 131072` only ever
lands in `[0, 131071]`, so this demonstration bit can NEVER collide with a
real credential (no false revocation) and can never be cleared. The served
list is therefore one byte longer than the W3C 131072-bit minimum — a
longer list is spec-conformant (the minimum is a floor). Confirm in one
pass: index 131072 → `1`, and any real/unassigned index (e.g. `0`) → `0`.
If 131072 reads `0`, your gzip/base64url decode or bit-order is wrong (the
list does carry the set bit). When a genuine credential is revoked its own
in-range bit flips `0→1` by the same mechanism (admin `POST
/api/admin/revoke`); the golden bit just lets you verify the decode +
rejection end-to-end without waiting for a live revocation.

**Stock-tooling caveat (cycle 867 honest disclosure)**: stock
didkit / waltid / Sphereon in strict mode WILL fail on this VC
because of the three variants above. Lenient-mode verifiers
will pass. **For a runnable reference implementation** in either language:
[`/docs/l1-verifier-python.md`](l1-verifier-python.md) (Python:
`pip install jcs pynacl base58 requests`) or
[`/docs/l1-verifier-node.md`](l1-verifier-node.md) (Node 18+ ESM:
`npm install bs58 json-canonicalize`).  Both verifiers implement
the three IP-specific variants, verify any IntelligencePro-issued
credential end-to-end, and document the same failure-mode matrix.

**Python version pin (cycle 881 — supply-chain-reverifier scr-878-P3-2)**:
the Python recipe is validated against CPython **3.11, 3.12, and
3.13**. Homebrew's CPython 3.14 (current as of 2026-05) ships a
broken `pyexpat` that prevents `python -m venv` from even
bootstrapping (`ModuleNotFoundError: pyexpat` during ensurepip).
Pin your venv to 3.11–3.13 explicitly:
`python3.13 -m venv .venv && source .venv/bin/activate && pip
install jcs pynacl base58 requests`. Stock CPython from python.org
works on every 3.11–3.13 release; Homebrew is the failure-prone
distribution channel here.

Migration status: item (c) — **the sha256-concat binding form —
SHIPPED in cycle 916** (this recipe and the wire now both use
`sha256(jcs(proofConfig)) || sha256(jcs(document))`). Two strict-W3C-DI
alignment items remain (out of scope here): (a) moving `proofScope`
to a top-level sibling of `proof` rather than inside proofConfig, and
(b) inheriting `@context` into proofConfig. Both would require a new
schema $id pin per cycle-462 and a producer-side migration window;
the current recipe matches the wire and verifies under a stock
eddsa-jcs-2022 (JCS) verifier today.

## L2: Judge attestation (high-assurance consumers)

The credential's `proof[1]` carries the judge's personal Ed25519
signature over IntelligencePro's canonical judgment payload. This is the
attestation that says "this specific judge, with this specific
public key, produced this specific judgment."

**The two trust legs are INDEPENDENT — read the right field (supply-chain
audit cycle 1019 P2).** On a verify-endpoint response obtained with
`?verifyJudgeAttestations=1`, the top-level `valid` reflects the L1 HMAC
ONLY (the manifest's covered-field integrity). It does NOT incorporate
judge-attestation validity: a tampered or non-verifying judge signature
leaves top-level `valid:true` (the HMAC is still intact). The judge-leg
verdict lives in a SEPARATE field — `judgeAttestations.allValid` (with
per-judge `results[]`). A relying party making a "the judges who signed
this are who they claim" assertion MUST read `judgeAttestations.allValid`,
never the top-level `valid`. (This mirrors the §L1 coveredFields rule:
absence of a signal in one leg is not presence of assurance in another.)

**Honest non-conformance**: `proof[1].type` is
`IntelligencePro.JudgmentProof`, NOT a W3C-registered cryptosuite.
Stock W3C verifiers will skip this proof (lenient mode) or
reject the entire VC (strict mode). This is intentional — the
durable fix is restructuring judge attestation as a separate
`did:key`-issued VC with its own `eddsa-jcs-2022` proof (Phase B
client-side signing). Until that ships, the cryptography is in
the proprietary container; relying parties wanting judge-level
attestation use the recipe below.

### Recipe

1. Extract `proof[1].signatureHex` (raw Ed25519, 128 hex).
2. Resolve `proof[1].verificationMethod` →
   `did:key:z6Mk…#z6Mk…` decodes to a 32-byte Ed25519 public key
   per `did:key` v0.7.
3. POST the credential to `proof[1].verifier` with the body shape
   in `proof[1].verifierRecipe`. The platform reconstructs the
   canonical judgment payload (the same canonicalization the
   judge applied) and runs the Ed25519 verify.
4. Alternative (no platform round-trip required): re-implement
   the canonical payload reconstruction following
   `app/lib/judge-attestation.ts:canonicalJudgmentPayload` and
   Ed25519-verify locally. The function is small and pure.

   **Pre-canonicalization score normalization (cycle 776 sc-772-P0-3
   close).** The signing path rounds every score field to 4
   decimal places via `Math.round(n * 10000) / 10000` BEFORE
   canonicalization (`app/lib/judge-attestation.ts:round4` L267-L270
   applied at L181/L188-L191 + at composite). An independent
   verifier that retains full IEEE-754 precision from the JSON
   parse — e.g. a score that round-trips to `0.7333333333333334`
   on the wire — produces different canonical bytes than the
   platform's signing path and fails the verify. Fields requiring
   `round4` before canonicalization: `composite`, `scores.accuracy`,
   `scores.clarity`, `scores.compression`, `scores.sources`.

   **Canonicalize-divergence caveat (sc-772-P1-5 carry-over).**
   The inline `canonicalize()` helper in `judge-attestation.ts`
   L251-L265 is the bytes Ed25519 actually signs. It is NOT the
   same function as `canonical-json.ts:canonicalize()` (which got
   the cycle-671 undefined-handling fix). For safe-envelope
   payloads (no `undefined` values inside `scores` or
   `evidenceCitations`) the two emit identical bytes. For inputs
   containing `undefined`, the two helpers diverge — the
   judge-attestation inline delegates to `JSON.stringify` which
   emits the JS literal `undefined` (invalid JSON); `canonical-
   json.ts` drops undefined-valued keys and emits `"null"` for
   undefined array elements per RFC 8785 §3.2.1.4. A polyglot
   verifier MUST reproduce the judge-attestation inline behavior
   (not the canonical-json.ts behavior) to verify L2 bytes.
   Consolidation under one source-of-truth helper is tracked at
   sc-772-P1-5; until that ships, the inline at L251-L265 is the
   canonical L2 byte-producer.

## L3: Manifest chain signature (Ed25519 when the chain key is provisioned; HMAC-only otherwise)

> **⚠ Current deployment status (sca-944-P0-1, cycle 945).** On
> deployments where the attestation-chain Ed25519 key is NOT provisioned
> — `ATTESTATION_CHAIN_SIGNING_KEY` unset, which is the live ip.tekton.cc
> default today — the envelope carries ONLY the HMAC `chainSignature`;
> `chainSignatureEd25519` is **omitted** and `/.well-known/jwks.json`
> exposes **no** `ip-attestation-chain-*` key. On those deployments L3
> bundle-composition integrity is **HMAC-only / platform-attested**
> (re-verify via `POST /api/knowledge/attestation/verify`), and is **NOT
> independently consumer-verifiable**. The Ed25519 recipe below applies
> ONLY where the key IS provisioned — the envelope's `note` and the
> presence of the `chainSignatureEd25519` field tell you which mode
> produced a given envelope. Independent Ed25519 L3 verification is
> roadmapped (durable fix = provision the key + publish the JWK), not
> universally present. Confirm with `/api/knowledge/attestation/verify`
> → `verifiedVia` (`"hmac"` vs `"ed25519"`).

The artifact / eval-result / brief / traversal manifests carry a
chain signature binding the bundle composition. As of cycle 627
(Phase B of the L3 HMAC → Ed25519 migration documented at
`/docs/l3-chain-migration-plan.md`), the envelope carries — **when the
attestation-chain Ed25519 key is provisioned** — BOTH:

- `chainSignatureEd25519` (PREFERRED — consumer-verifiable via the
  JWK at `/.well-known/jwks.json` under
  `kid=chainSigningKeyIdEd25519`; Ed25519 signature over
  sha256(canonicalJSON(input))) — added cycle 627
- `chainSignature` (DEPRECATED per Phase C, cycle 687 — emitted for
  back-compat only; HMAC-SHA256 with a platform-internal key)
- `chainSignatureEd25519` is the canonical form. **Relying parties
  should prefer the Ed25519 signature** and treat the HMAC field as
  a back-compat anchor for pre-Phase-B consumers.

### Phase C deprecation window (cycle 687, expires ~cycle 720+)

Per the published migration plan (`/docs/l3-chain-migration-plan.md`),
Phase C is the 6-cycle (≈ ~hours-to-days at autonomous-run cadence)
deprecation window during which:

- The envelope continues to emit BOTH signatures (back-compat)
- The verification-recipe doc (this file) declares HMAC
  "deprecated; emitted for back-compat only"
- External callers migrate their verification path to
  `chainSignatureEd25519`
- The platform's own `/api/knowledge/attestation/verify` endpoint
  accepts EITHER signature (cycle 629 Phase B.2 wired this; per-form
  outcomes surfaced in the response so a Phase-C consumer can
  detect partial-tamper scenarios at this transition layer)

After Phase C closes:
- **Phase D**: stop emitting HMAC on new envelopes; remove the
  marketing-claim hedge from `app/page.tsx`
- **Phase E**: remove HMAC verification entirely after consumer-
  cache aging; rename `chainSignatureEd25519` → `chainSignature` in
  the envelope shape (major version bump)

### Relying-party action (Phase C onward)

1. Read `chainSigningKeyIdEd25519` from the envelope.
2. Fetch the public key at `/.well-known/jwks.json` filtered by
   `kid` (look for the `ip-attestation-chain-*` prefix per
   cycle 626).
3. Compute `sha256(canonicalJSON({artifactPath,
   artifactSignature, evalSignatures[sorted], evalSummary,
   issuedAt, issuedTo}))` per the recipe pinned at
   `chainCanonicalPayloadShape` in the envelope.
4. Ed25519-verify the signature against your public key + the
   sha256 digest.

   **Signed-bytes shape (cycle 785 sc-772-P1-4 close).** The
   bytes Ed25519 actually signs are the UTF-8 encoding of the
   128-character LOWERCASE HEX ASCII representation of the
   sha256 digest — NOT the raw 32-byte digest. The emitter at
   `app/lib/attestation-chain.ts` (chainSignatureEd25519
   production path) computes `sha256(...).digest("hex")` then
   passes the 64-char hex string to `signJudgmentPayload` which
   wraps it as `Buffer.from(payload, "utf8")` before calling
   `crypto.sign(null, …, keyObj)`. A verifier passing the raw
   32-byte digest to their Ed25519 library gets verify:false on
   every envelope — a silent failure mode. Use Buffer/bytes of
   the 64-character hex digest as the message: `verify(sig,
   Buffer.from(digestHex, "utf8"), pubKey)` in Node, or the
   language equivalent (Python `nacl.signing.VerifyKey.verify(
   digest_hex.encode("utf-8"), sig)`; Rust `ed25519_dalek::
   VerifyingKey::verify(digest_hex.as_bytes(), &sig)`; Go
   `ed25519.Verify(pub, []byte(digestHex), sig)`).

   **kid derivation (cycle 785 sc-772-P1-4 close).** The
   `chainSigningKeyIdEd25519` kid is derived as:
   `"ip-attestation-chain-" + sha256(publicKeyHex_AS_UTF8).slice(0,12)`
   where `publicKeyHex_AS_UTF8` is the UTF-8 encoding of the
   128-character lowercase hex ASCII of the raw 32-byte public
   key — NOT the raw 32 bytes themselves and NOT an RFC 7638
   JWK thumbprint. A verifier reproducing the kid from
   `/.well-known/jwks.json`'s `x` field MUST:
   (a) base64url-decode `x` → 32 raw bytes,
   (b) hex-encode lowercase → 128-char ASCII string,
   (c) sha256 over the UTF-8 bytes of THAT hex string,
   (d) take first 12 hex chars of the result,
   (e) prepend `"ip-attestation-chain-"`.
   This is NON-STANDARD relative to RFC 7638; the platform
   pre-dates that spec for this key and the hex-string
   derivation stayed for back-compat with chain envelopes
   issued cycle 626+. A future cycle MAY emit a parallel
   RFC-7638 thumbprint as `kid_rfc7638`; until then, the
   derivation above is the contract.
5. If both `chainSignature` (HMAC) AND `chainSignatureEd25519` are
   present and exactly one verifies, that's a partial-tamper
   signal — REFUSE the envelope (cycle 629 Phase B.2 logic).

### Recipe (for HMAC back-compat — DEPRECATED per Phase C)

Only relevant if you're a pre-Phase-B consumer that hasn't
migrated yet:

1. Fetch `/api/knowledge/signing-keys` to discover the canonical
   payload shape.
2. Recompute HMAC-SHA256 over the canonical payload using the
   shared key (only the platform has this).
3. Compare to `chainSignature`.

**This path stops working after Phase D removes HMAC emission.**
Migrate to the Ed25519 recipe above.

### Why the HMAC stayed this long

The manifest layer predates L1 (manifests: ~cycle 30, L1: cycle
305). HMAC was the original chain-of-custody mechanism before the
platform grew an asymmetric signing layer. Today (Phase C onward)
the Ed25519 signature is the canonical form; HMAC is back-compat
for consumers still in flight on the migration.

### Phase C exit criteria

Per the migration plan, Phase D moves forward when:

- Consumer-side telemetry shows ≥6 cycles have elapsed since
  Phase B landed (cycle 627 → expects Phase D around cycle 633+
  the 6-cycle aging window; with the autonomous-run cadence, that's
  ~hours of wall clock)
- No external partner reports breakage on the Ed25519 path during
  Phase C
- The cycle-629 `verifyAttestationChain` per-form-outcomes surface
  hasn't seen partial-tamper signals at a rate suggesting
  Ed25519-side issuance bugs

### judgesDigest preimage — verify the manifest ↔ judges binding (cycle 1222)

Every brief / artifact / eval-result manifest carries `judgesDigest` in its
HMAC-covered (L3) field set. It binds the manifest to the EXACT set of judges
that decided the proposal, so a relying party can confirm "this manifest
commits to THESE specific judges" — the link between the L2 judge leg and the
L3 manifest — without trusting the platform's source. A relying-party
verification road-test (cycle 1222) found the preimage undocumented and could
not reproduce it (it guessed pubkeys/sigs; the real inputs are the scoring
fields). The exact, reproduced computation:

1. Fetch the judges: `GET /api/knowledge/proposals/{publishingProposalId}` →
   `judgments[]`. Each row carries everything you need: `{id, agentTag,
   weight, composite, scores, judgeTier, judgeIntelligenceScore, judgeIpCidr,
   receivedAt}`.
2. Sort the judgments by `id` ascending (String comparison).
3. Map each judgment to EXACTLY this object — note the defaults:
   `{ id, agentTag, weight, composite, scores, judgeTier (?? ""),
   judgeIntelligenceScore (?? 0), judgeIpCidr (?? ""), receivedAt }`.
   - `scores` is the RAW `{accuracy, clarity, compression, sources}` object,
     full precision — do NOT round. (This differs from the L2 proof[1]
     judge-leg payload, which rounds scores/composite to 4dp. Rounding here is
     the #1 mismatch artifact.)
   - `weight` / `composite` are the stored full-precision JS floats
     (e.g. `1.8276999999999997`) — use them verbatim.
   - Do NOT include `publicKey`, `signature`, `rationale`, or
     `proposalTargetSha256` — they are not in the preimage.
4. Canonicalize the resulting ARRAY with the platform's **sorted-key
   canonical-JSON, NOT RFC-8785 JCS**: recursively sort object keys ascending,
   drop `undefined`-valued keys, compact separators (no spaces), arrays keep
   element order, primitives via standard JSON serialization (NO RFC-8785
   number normalization). This is `app/lib/canonical-json.ts:canonicalize` —
   deliberately NOT the eddsa-jcs-2022 canonicalizer used for the L1 proof.
   Using RFC-8785 JCS here yields a MISMATCH.
5. `judgesDigest = sha256_hex(canonicalJson)`. Compare to
   `manifest.judgesDigest`.

For these value types the platform serializer coincides with Python
`json.dumps(arr, sort_keys=True, separators=(",", ":"))`, so a one-liner
reproduces it (empirically verified cycle 1222 against
`kb:list-reordering-and-ranking` → `c9547d79…`):

```python
import json, hashlib
js = proposal["judgments"]
canon = [{ "id": j["id"], "agentTag": j["agentTag"], "weight": j["weight"],
  "composite": j["composite"], "scores": j["scores"],
  "judgeTier": j.get("judgeTier") or "",
  "judgeIntelligenceScore": j.get("judgeIntelligenceScore") or 0,
  "judgeIpCidr": j.get("judgeIpCidr") or "", "receivedAt": j["receivedAt"] }
  for j in sorted(js, key=lambda x: x["id"]) ]
digest = hashlib.sha256(
  json.dumps(canon, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
# digest == manifest.judgesDigest  → manifest provably commits to these judges
```

A match proves the HMAC manifest commits to exactly these judge records; the
L2 recipe above then independently Ed25519-verifies each judge's signature.
Together they close the manifest ↔ judges binding.

### Independence check — prove no judge is the proposer (no self-review) (cycle 1222)

The wedge is INDEPENDENT peer attestation, so a relying party must be able to
confirm no judge is also the proposer. The manifest carries
`proposerAgentTagHash` (the proposer's tag, hashed) and the judges carry their
`agentTag` in plaintext — but in DIFFERENT canonical forms, so the obvious
check is a trap:

- judge `agentTag` (in the judgment VC + `proposals/{id}.judgments[]`) is
  `"agent_" + apiKey[3:11]` (e.g. `agent_27390751`).
- `proposerAgentTagHash = sha256( apiKey.slice(0,11) )`, i.e.
  `sha256("ak_" + apiKey[3:11])` (e.g. `sha256("ak_27390751")`).

Both encode the SAME 8 hex chars (`apiKey[3:11]`) but with different prefixes
(`agent_` vs `ak_`). A verification road-test (cycle 1222) computed
`sha256(agentTag)` directly and got "no collision" — but that naive form can
NEVER equal `proposerAgentTagHash` even when the proposer IS a judge, so it is
a FALSE assurance of independence.

Correct check — for each judge, reconstruct the `ak_` form before hashing:

```python
import hashlib
def judge_is_proposer(judge_agent_tag, proposer_agent_tag_hash):
    hex8 = judge_agent_tag[len("agent_"):]            # strip the agent_ prefix
    ak_form = "ak_" + hex8                            # the form the manifest hashed
    return hashlib.sha256(ak_form.encode()).hexdigest() == proposer_agent_tag_hash
# Independence holds when judge_is_proposer(...) is False for EVERY judge.
# (proposerAgentTagHash == "" means an anonymous proposal — no proposer
#  identity to compare; independence vs. the proposer is vacuous/unknowable.)
```

Verified by construction (cycle 1222): `sha256("ak_"+hex8)` reproduces
`proposerAgentTagHash`; `sha256("agent_"+hex8)` never does. Pair this with the
L2 judge-leg signatures (distinct judge `publicKey`s ⇒ distinct judges) and
the judgesDigest binding above for the full independence story. NOTE: this
rules out the proposer self-judging; it does NOT by itself rule out colluding
DISTINCT judges — for that, cross-check the L2 `attesterLineage` /
`judgeStateSnapshot` correlation signals.

## L4: Artifact resolution (cycle 675 — EHO-659-F5 P1)

L1/L2/L3 verify signatures over canonical bytes; they don't tell
you **where to fetch the bytes**. For eval-attestation consumers
(regulator, court-of-record, downstream auditor), bootstrapping a
re-run requires resolving each `harnessVersionSha` /
`datasetSha` / `evalCodeSha` / `modelVersionSha` /
`sandboxTaskSetShaEcho` → actual artifact bytes. Pre-cycle-675 the
schema descriptions waved hands at this; the platform exposed no
`/api/artifacts/by-sha/<sha256>` resolver; held-out dataset splits
had no public mirror.

Cycle 675 adds an OPTIONAL `artifactResolvers[]` array on
`ip.eval.run.attestation.v1` where the issuer commits URI hints
alongside each sha:

```json
{
  "artifactResolvers": [
    {
      "sha256": "<the 64-hex sha already bound elsewhere in the attestation>",
      "uri": "https://files.pythonhosted.org/packages/.../lm_eval-0.4.5-py3-none-any.whl",
      "artifactRole": "harnessBinary",
      "mirror": "pypi",
      "freshAsOf": 1747534080000
    },
    {
      "sha256": "<...>",
      "uri": "https://huggingface.co/datasets/cais/mmlu/resolve/<commit>/data.parquet",
      "artifactRole": "dataset",
      "mirror": "huggingface-hub"
    }
  ]
}
```

### Trust model — issuer-asserted, NOT platform-verified

The platform DOES NOT fetch the URI at issuance, DOES NOT recompute
the sha, and DOES NOT validate the mirror is up. Mirrors the
cycle-493 `parentSha256` honest-disclosure posture: a dangling URI
is an UNVERIFIED hint, NOT a verified absence.

### Verifier walk

A consumer holding an `ip.eval.run.attestation.v1` with
`artifactResolvers[]` populated re-runs as follows:

1. **For each sha-bound field in the attestation** (`harnessVersionSha`,
   `datasetSha`, `evalCodeSha`, `modelVersionSha` if present,
   `sandboxTaskSetShaEcho` if present): find the matching
   `artifactResolvers[i]` entry by sha-equality.
2. **Fetch `entry.uri`** with the appropriate client (pip for pypi,
   `huggingface_hub` for huggingface-hub, `gh release download` for
   github-releases, IPFS gateway for ipfs, S3 SDK for s3-public,
   issuer-provided credentials for operator-private).
3. **Recompute** sha256 of the fetched bytes. If it doesn't equal
   `entry.sha256`, REFUSE the attestation — the issuer's URI hint
   diverged from the bound bytes (mirror has moved, mirror was
   never the source, OR attestation is forged).
4. **For `datasetHeldOutSplit` role**: the issuer SHOULD provide
   either operator-private credentials out-of-band OR an ipfs:// fallback;
   if neither resolves, the verifier cannot bootstrap re-run and
   the attestation's "skeptic can re-run" claim is unverifiable
   for that consumer (NOT invalid — just unverifiable).

### artifactRole enum

- `harnessBinary` — lm-evaluation-harness wheel, Inspect AI binary, etc.
- `evalCode` — the eval harness's task definition (lm-eval `--tasks`
  YAML, Inspect AI task module, Promptfoo `prompts.yaml`)
- `dataset` — public dataset (MMLU, HELM, etc.)
- `datasetHeldOutSplit` — held-out subset; typically no public mirror
- `modelWeights` — full model checkpoint
- `modelAdapter` — PEFT/LoRA adapter (binds to a base model)
- `sandboxImage` — Docker/OCI sandbox image for agent-benchmark
  harnesses (OSWorld/WebArena/SWE-bench/etc.)
- `judgePrompt` — LLM-as-judge system prompt (paired with
  `judgeStack` block)
- `judgesManifest` — full judges manifest (paired with `judgesDigest`)
- `other` — escape hatch

### mirror enum

`pypi` / `huggingface-hub` / `github-releases` / `github-raw` /
`ipfs` / `s3-public` / `operator-private` / `other`. Lets a
consumer route to the right resolver library without URI-host
parsing.

### honestDisclosure

The cycle-493 `parentSha256` description carries an honest-
disclosure paragraph ("dangling pointer = UNVERIFIED chain link,
NOT verified absence"). The same posture applies to
`artifactResolvers[].uri`: a consumer who fetches and gets either
404 or a sha-mismatch knows the issuer's hint is stale or wrong;
they don't know whether the bytes the sha bound to ever existed
on a public mirror.

### Future cycle: server-side resolver

A future cycle MAY ship `/api/artifacts/by-sha/<sha256>` that
proxies to known mirrors (pypi index + huggingface_hub API +
github releases search) with a per-mirror trust contract. Until
then, the issuer-asserted `artifactResolvers[]` is the canonical
path. See EHO-659-F5 audit lineage at
`/tmp/eval-harness-operator-findings-cycle659.md` for the
trade-off analysis.

## Rebound detection (eval-result manifests — cycle 63 / cycle 100)

A valid HMAC signature on an eval-result manifest proves the
manifest is authentic AND that the artifact / harness / dataset
bytes were *as recorded at sign time* (the manifest binds a
`sha256AtSign` for each). It does NOT prove the bytes at those
paths are *still* the same today. A path is mutable — the
artifact a manifest attests can be replaced (a "rebound") while
the manifest's signature continues to verify.

`POST /api/knowledge/eval/verify` surfaces this as a SEPARATE
axis from `valid`. When the HMAC verifies, the response carries:

- `valid: true` — the signature + covered fields are intact.
- `artifactRebound` / `harnessRebound` / `datasetRebound`
  (booleans) — `true` when the current bytes at that path no
  longer hash to the manifest's recorded `sha256AtSign`.
- `artifactSha256Current` / `harnessSha256Current` /
  `datasetSha256Current` — the bytes the path resolves to NOW.
- `reboundNote` — human-readable summary of which path moved.

**Why a verifier MUST check both axes.** `valid: true` alone is
NOT sufficient to treat the eval as binding to the *current*
artifact. A consumer that gates on `valid === true` and ignores
the rebound flags will accept an eval result whose subject
silently changed underneath it — exactly the "the signature still
verifies but it's measuring different bytes now" failure. The
binding check is:

```
valid === true
  && artifactRebound === false
  && harnessRebound  === false
  && datasetRebound  === false
```

A rebound is NOT necessarily malicious — a benign re-publish at
the same path reads identically to an adversarial swap. The
platform cannot distinguish them; it reports the divergence and
leaves the trust decision to the relying party. If you only care
that the eval was validly produced (not that it still binds to
the live artifact), `valid: true` suffices; if you are about to
ACT on the eval against the current artifact (gate a deploy,
promote a model), require the rebound flags clear too.

This composes with L4 above: `*Sha256Current` is the bytes the
path resolves to under the platform's view; the `artifactResolvers[]`
hints are the issuer-asserted mirrors you'd fetch to independently
recompute the sha and confirm.

## Chain-of-custody walk: schema declaration vs issuance reality (cycle 801 rc-795-P0-1 honest-disclosure)

**Status**: SCHEMA SHIPPED, ISSUANCE PENDING.

The cycle-484 substrate added `chain.parentSha256` + `parentKind`
+ `parentAttestationId` to the 21 attestation schemas at
`/credentials/<slug>/v1` (cycles 778-784 final-close of the
sc-772-P0-1 sweep). A verifier reading any schema sees the
declared upward link and concludes the platform produces
attestation bodies that mechanically chain to their parent.

**That conclusion is currently wrong.** The regulatory-
compliance audit at cycle 795 (rc-795-P0-1) verified that NO
issuance code path writes `chain.parentSha256` into an outbound
attestation body. The fields are declared OPTIONAL on every
schema (preserving back-compat for pre-cycle-778 attestations)
and the issuance paths never populate them — so every real
attestation served from `/api/credentials/judgment/...` and
`/credentials/<slug>/v1/...` has `chain: undefined` today.

What this means for a verifier:

- **Reading the schema**: `chain` is declared with `parentSha256`
  + `parentKind` enum + optional `parentAttestationId`. A verifier
  building tooling against the schema sees a real cycle-484
  substrate.
- **Reading a real attestation**: `chain` is absent. The verifier
  bottoms out at the single leaf and cannot walk upward. The
  cycle-484 cycle-493 honest-disclosure ("platform does NOT
  verify parent-existence at issuance") is correct as far as it
  goes, BUT pre-cycle-801 it implicitly suggested the parent WAS
  populated and only existence was unverified. Cycle 801 corrects
  the implicit claim: the parent is NOT populated at all.

What is currently populated in lieu of a `chain.parentSha256`:

- **Judgment credentials** at `/api/credentials/judgment/<kind>/<proposalId>/<judgmentId>` carry
  `credentialSubject.proposalTargetSha256` (sig.proposalTargetSha256 in the issuance code) — this IS the proposal-target sha256 the judgment is ABOUT, but it lives at credentialSubject level, not inside a `chain` object, and points DOWN (to the artifact under judgment) not UP (to a parent attestation).
- **Artifact / eval-result / brief / capability / decision-graph manifests** carry per-kind
  upward refs (e.g. evidenceShaArray[], experimentReceiptId,
  inputPipelineRunIds[], sandboxRunId, citedEvidenceShaArray[])
  in their NATIVE shape — these are the pre-cycle-484 chain
  primitives the cycle-484 substrate was meant to unify.
- **L3 attestation-chain Ed25519 signature** (`chainSignatureEd25519` per cycles 627/687) — this DOES bind the bundle composition but it's at a DIFFERENT layer (the L3 manifest-chain, not the L1 VC body's `chain` object).

### Verifier guidance until issuance lands

Treat `chain.parentSha256: absent` as the CURRENT substrate state, NOT as a verification failure:

1. When walking an attestation chain, FIRST look for the per-kind native upward ref (the cycle-484 substrate's legacy walk noted in `wedgeTiersPayload.walkUpRecipe[4]`):
   - `governance.compliance.evidenceShaArray[]`
   - `leaderboard.receipt.experimentReceiptId`
   - `experiment.receipt.inputPipelineRunIds[]`
   - `review.attestation.citedEvidenceShaArray[]` (the review's native evidence refs — each item {kind, sha256, scope})
   - `tutorial.citation` / `retrieval.citation` / `support.resolution` — these point at experiment-receipt or pipeline-facet via NATIVE refs documented in each schema's description
2. THEN, when `chain.parentSha256` lands at issuance time (cycle 802+), the cycle-484 substrate becomes the canonical walk; the per-kind native refs continue to work for back-compat with all attestations issued before that cycle.
3. If you're building a verifier today and you NEED the upward walk, code against the per-kind native refs; the `chain` block can be added as an alternate path when issuance starts populating it.

### Migration plan (cycles 802+)

> **Status (cycle 1074): this plan has NOT shipped as written — treat the cycle numbers below as the original (unmet) estimate, not a delivered timeline.** Verified cycle 1074: no served credential surfaces a `chain` block. The judgment VC at `/api/credentials/judgment/<kind>/<proposalId>/<judgmentId>` builds `credentialSubject` with `proposalId` / `proposalTargetSha256` / `aiDisclosure` / `judgment{}` and NO `chain` — so the cycle-801 consumer observation ("`chain: absent` on the served attestation") still holds platform-wide. The synthetic.media issuance-side population predicted for "cycle 802" never landed. The ONLY chain progress to date is eval-result **propose-time** persistence (cycle 897): a producer may POST a `chain` block to `/api/knowledge/eval/propose` and it is stored on the proposal record, but that is producer-side persistence, NOT the issuance-side surfacing this plan describes, and it does NOT appear on the served VC. Issuance-side `chain` surfacing remains uncommitted future work with no scheduled cycle. The per-kind native upward refs (see §"Verifier guidance" above) remain the working chain-walk today.

Issuance-time population will roll out per-kind, starting with the highest-EU-load-bearing leaves:

- **Cycle 802 candidate**: `ip.synthetic.media.attestation.v1` issuance path populates `chain` against `ip.experiment.receipt.v1` (the generator's weights checkpoint receipt) — the Article-50(2)-anchor schema (€15M/3% turnover penalty surface; AI-content-marking enforcement **2026-12-02**, deferred from 2026-08-02 by the 7 May 2026 Digital Omnibus — synthetic-media labelling is Art 50(2), NOT the Art 50(1)/(3) transparency window; see `/.well-known/control-mapping.json` for the authoritative split).
- **Cycle 803+**: other leaves in priority order: tutorial-citation, retrieval-citation, support-resolution, legal-citation, vulnerability-disclosure.
- **Per-cycle scope**: ONE issuance path per commit, end-to-end (read parent attestation, sha256 its canonical-JSON, inject `chain` block, smoke regression that asserts the field appears on a real propose+publish round-trip).
- **Back-compat invariant**: pre-issuance-sweep attestations remain valid (the field stays OPTIONAL on all schemas); verifiers MUST tolerate `chain: absent` on attestations issued before their respective cycle.

This block is the cycle-801 honest disclosure. Pre-cycle-801 the platform's chain-of-custody marketing claim ("verifier walks the chain mechanically") was true only at the schema-declaration level; the issuance reality is per-kind native refs until the cycles 802+ sweep completes.

### Producer-side honest disclosure (cycle 810 prod-808-P0-2)

The cycle-801 disclosure above explained what CONSUMERS see (chain block absent at read time). It did NOT explain what PRODUCERS see when they try to populate the chain block themselves. The cycle-808 producer-persona audit (prod-808-P0-2) caught the gap. The producer reality today:

- **Propose-body validators silently drop the field.** The 7 propose-route body validators (`validateArtifactProposal`, `validateEvalProposal`, etc.) define their accepted-field set explicitly. A `chain` field is NOT in any of those sets. A producer who POSTs `{path, title, spec, payload, chain: {parentSha256: "<sha>"}}` to e.g. `/api/knowledge/artifact/propose` gets a 200 OK with the chain field SILENTLY STRIPPED before the proposal record is stored. The producer cannot tell from the response whether the parent sha was accepted-and-stored or accepted-and-stripped.
- **No producer error envelope today.** The validators don't return a "chain.parentSha256: not yet supported at propose time" error — they treat unknown fields as harmless extras and drop them. This matches the pre-cycle-810 convention but combined with the schema's OPTIONAL declaration it's the worst-of-both: schema says the field exists; propose accepts the body; field never reaches the issued attestation.
- **An AISI eval-harness producer emitting 10k eval-results/day where every single one has a known parent artifact.attestation sha256 has no API today** to express that linkage. The propose route accepts the body; the chain is dropped; the issued attestation has `chain: absent` (matching cycle-801's consumer-side observation).

> **Update (cycle 1074 — supersedes the two bullets above FOR THE eval-result kind).** Cycle 897 (eval-harness probe ehl-895-P0-2) added `chain` pass-through to the **eval-result** propose validator. `POST /api/knowledge/eval/propose` with a `chain: { parentSha256, parentKind, parentAttestationId? }` block now PARSES + validates + PERSISTS it — it is NOT silently dropped — closing exactly the "no API today" gap the bullet above describes, for eval-results specifically. The `validateArtifactProposal` and other-kind validators named in the first bullet STILL drop `chain` (so that bullet remains accurate for every kind EXCEPT eval-result). Caveat unchanged: a pass-through `chain.*` rides OUTSIDE the per-schema `signature` pre-image (aa-824 / cycle-825), so at the offline-`signature` layer it is ADVISORY pointer-shape, not cryptographically bound, until the v2 schema slug widens the pre-image; the full-integrity path remains the L1 W3C DataIntegrityProof envelope (which signs the full body incl. `chain`). Net: for eval-results, producer-guidance item 1 below is REVERSED — you CAN supply `chain` at propose time today.

**Producer guidance until cycle-802+ issuance sweep lands**:

1. **Do not depend on chain pass-through.** Treat the chain block as schema-declared but propose-time-unsupported. Continue to populate per-kind native upward refs in your propose body (`payload.experimentReceiptId`, `payload.inputPipelineRunIds[]`, etc.) — those ARE accepted and stored.
2. **If you have parent shas ready**, you can include them in the propose body's optional metadata fields if the schema defines them; otherwise persist them in your own producer-side records keyed by the platform's issued `proposalId` until the platform's per-kind issuance code adopts pass-through.
3. **Watch the cycle-802+ migration plan above** for the per-kind rollout. Once a kind's issuance code populates `chain` server-side, the platform will also begin accepting `chain` on the propose body (audit's fix variant (a) — pass-through).

**For consumers reading propose-response bodies**: The issued attestation's `chain` block is the canonical source. The propose response itself does NOT reflect input `chain` even when an issuer eventually pass-throughs (the response shape is fixed pre-issuance; the chain only lands on the published attestation). Consumers needing chain-walk should fetch `/api/credentials/<kind>/<sha>` and verify the `chain` block there.

This producer-side disclosure closes prod-808-P0-2; until cycle-802+ rollout, the producer side of the wedge is informationally honest.

### Integrity-side honest disclosure (cycle 830 aa-824-P0-1, dual-path teaching)

The cycles 801 + 810 disclosures above cover the **resolvability** axis: today most attestations carry `chain: absent` and the per-kind native refs are the dominant walk. The cycle-824 adversarial-auditor finding (aa-824-P0-1) surfaced a separate axis — **integrity**: even when `chain` IS populated, the per-schema Ed25519 `signature` field on every wedge-tier (and chain-bearing leaf) schema does NOT include the `chain.*` block in its signed pre-image. The signed pre-image enumeration in each schema's `signature.description` stops at the per-kind fields (e.g. `runnerDid` for experiment.receipt, `ipAttestationEventId` for review.attestation); `chain` and `extra` are unsigned at the offline-verification layer.

Practical consequence for an offline verifier (the documented `signature`+`publicKey` path, marketed for "offline / embedded verification when network resolution is unavailable"):

- A wire-path attacker or post-hoc rewriter (stale CDN, attacker-controlled mirror, malicious storage intermediary) can swap `chain.parentSha256` / `chain.parentKind` / `chain.parentAttestationId` on an already-signed attestation without invalidating `signature`.
- The wedge's "mechanical walk from leaf to root" can be redirected to a fabricated or unrelated parent without breaking the issuer's signature.
- The same applies to the `extra` block (additionalProperties: true, also outside the pre-image) — large unsigned bytes can ride alongside a verifiable attestation. The `extra` description tells consumers to "ignore unknown keys" but doesn't tell them those keys are also UNSIGNED.

**Dual-path verifier recommendation (today's correct posture):**

1. **For shape / attribute checks (offline-friendly, lightweight):** verify the per-schema `signature` against `publicKey`. The signed fields (per-schema field enumeration in `signature.description`) are cryptographically bound. Treat any `chain.*` field present in the body as ADVISORY pointer-shape — useful for hypothesis-formation, NOT cryptographic evidence.

2. **For full-integrity verification (chain-walk required, regulator-grade):** fetch the W3C DataIntegrityProof envelope from `/credentials/<sha>/v1` or `/api/credentials/judgment/...`. The L1 VC envelope's `proof[0]` signs the FULL canonical-JSON body via `cryptosuite=eddsa-jcs-2022` (cycle 368 P0-1 binds proofConfig into signed bytes). That signature DOES cover `chain.*` and `extra`. A consumer that needs to walk a chain mechanically — or that's preparing court-of-record evidence under EU AI Act Article 19 — uses this path.

The wedge-tier schemas (review/experiment/leaderboard/governance/alignment) carry the disclosure in their own `signature.description` as of cycles 825-829. Cycle 830 teaches the dual-path posture in this canonical recipe so verifiers don't need to read 5 schema descriptions to discover it.

**Migration target**: v2 schema slugs ($id pin per cycle-462 sp-457-P2-2) will widen the per-schema pre-image to include `chain` and `extra`. At that point the offline path becomes integrity-equivalent to the W3C-VC path for new attestations; pre-v2 attestations retain the cycle-825-onward documented gap.

This block does NOT change behavior — it surfaces the integrity property of the existing two paths so verifiers select correctly today.

## Composing the three layers

For a "this judgment was made by judge X about artifact Y
through IntelligencePro's pipeline" claim:

- L1 confirms IntelligencePro issued the credential about Y.
- L2 confirms judge X is the source of the scores.
- L3 confirms the bundle composition: which artifacts, which
  evals, which judgments compose the credential. Per the L3
  recipe (§96+ below — Phase B/C cycles 627/687), L3 is now
  signed with the platform's separate attestation-chain
  Ed25519 key, **independently verifiable** by any relying
  party that resolves `chainSigningKeyIdEd25519` against
  `/.well-known/jwks.json`. The HMAC-SHA256 leg is preserved
  for back-compat per Phase C; Phase D removes it.

A relying party that verifies L1 + L2 + L3 has the full
cross-organizational cryptographic chain — issuer identity,
judge identity, and bundle composition all independently
attested without trusting IntelligencePro's internal pipeline.
A relying party that verifies only L1 has the "issued through
IntelligencePro" attestation but defers judge identity to
IntelligencePro's word; a relying party that verifies L1 + L2
defers bundle composition (which specific judgments map to
which artifact) to IntelligencePro's word.

## Status-list freshness (cycle 371)

The status-list VC carries an ETag derived from the encoded
bitstring state. Relying parties polling the list:

1. Initial GET: receive 200 + ETag + Last-Modified.
2. Subsequent GETs with `If-None-Match: <ETag>`: receive 304
   when the registry hasn't updated (zero body bytes).
3. When a revocation lands (admin writes
   `.data/revoked-indices.json`), the next GET returns 200 + a
   new ETag + the updated bitstring.

Recommended poll cadence: every 60 seconds during high-stakes
verification windows; every 5-15 minutes for steady-state.

### Freshness contract (cycle 388, regulatory R-386-7)

`cache-control: max-age=60` lets a downstream proxy serve a stale
response under stale-while-revalidate without telling the client.
A high-stakes verifier (a regulator running `waltid --strict`, a
Drata import worker, a court-of-record reader) needs a spec-level
statement of how stale the answer may be before it should be
treated as INCONCLUSIVE rather than authoritative. The status-list
response carries that statement as explicit headers:

| Header | Value | Meaning |
|---|---|---|
| `IP-Freshness-Contract-Version` | `v1` | Bumps to `v2` if any of the below ever change. |
| `IP-Freshness-Max-Age-Seconds` | `60` | The platform's own authoritative max-age. |
| `IP-Freshness-Stale-After-Seconds` | `120` | Past this many seconds beyond max-age, treat as inconclusive + re-fetch from the origin. |
| `IP-Freshness-Recommended-Poll-Seconds` | `60` | Recommended poll cadence for high-stakes pollers. |

Why headers and not VC body fields: the body is W3C
`BitstringStatusListCredential`-conformant; adding non-standard
fields breaks strict-mode verifiers. Headers are inspectable
separately and don't affect the signed bytes.

Relying parties SHOULD treat any `Date`-vs-fetch-time delta
exceeding `IP-Freshness-Max-Age-Seconds + IP-Freshness-Stale-After-
Seconds` as inconclusive (re-fetch from the origin, not from the
intermediate cache; use a fresh `If-None-Match: *` if needed). The
contract version stamp gives future deployments a way to evolve the
semantics without breaking existing clients — a client that ignores
unknown header values gracefully degrades to the cycle-371 implicit
contract.

The headers are present on BOTH 200 and 304 responses, so a polling
client that 304s for hours still sees the contract bound to the
freshness state it's revalidating against.

**CloudFront staleness note (cycle 881 — supply-chain-reverifier
scr-878-P3-1)**: cycle 878 bucketed `proof.created` to a 60-second
window, so within any bucket the signed bytes are deterministic
and the ETag is stable. The cycle-866 BOOT_EPOCH_MS mix-in plus
cycle-878 bucket mix-in mean ETag flips on every bucket boundary
AND on every deploy. However: a CloudFront PoP can legitimately
serve a cached 200 (with the prior bucket's ETag) for up to one
additional `max-age=60` window past a bucket boundary, because CF
respects the response's own `Cache-Control: max-age=60,
must-revalidate` rather than the bucket clock. Worst case is
therefore ~60s of stale `proof.created` past a boundary, which
sits inside the `IP-Freshness-Stale-After-Seconds: 120` envelope —
naive verifiers comparing `Date`-vs-fetch-time should NOT flag
this as a freshness violation. Strict verifiers wanting tighter
than 60s staleness can either (a) bypass CF with `?cb=<rand>` or
(b) issue `If-None-Match: *` for an unconditional origin fetch.

## Anti-replay binding (cycle 372)

Relying parties wanting per-recipient anti-replay can request a
judgment VC with `?audience=<their-did>&challenge=<fresh-nonce>`:

  GET .../judgment/.../?audience=did:web:tenant-x.com&challenge=H3K7y9Q2

The platform binds both fields into the signed proofConfig.
Re-presenting the VC to a different relying party fails the
audience check; re-presenting with a stale nonce fails the
challenge check. Both are cryptographically covered.

## Carry-over

Spec deltas not yet resolved (tracked in commit bodies of cycles
365-374):

- L3 HMAC mid-layer → Ed25519 (P1-7 durable fix).
- L2 judge attestation as a proper W3C VC (Phase-B migration,
  P1-6 durable fix).
- Key rotation runbook + admin endpoint (cycle 373 scaffold is
  live; rotation flow ships separately).
- Status-list admin write endpoint (cycle 369 scaffold is live;
  admin POST /api/admin/revoke pending).

## L1 corrigenda — empirical agent-tester history (2026-05-18 → 23)

This section is preserved as a historical pointer for integrators
who wrote code against pre-cycle-865 L1 verification advice.  The
authoritative L1 contract now lives in §"L1: W3C VC envelope" above
(cycle-865 inclusion fix + cycle-867 three-variant disclosure).

**Timeline**:

- 2026-05-18 / 19 ("verifier-side researcher" R1, "doc-walker" R4
  agent-testers): independent variant sweeps using `node:crypto` +
  vendored RFC 8785 JCS + base58btc could not reproduce a valid
  signature for the live `/credentials/status/v1` VC.  Identified
  two deviations from the W3C DI spec: (a) `proofScope` added
  AFTER signing, (b) raw-concat binding instead of hash-concat.
- 2026-05-23 cycle 865: the platform fixed (a) by including
  `proofScope` in the proofConfig that is canonicalized + signed.
  **The old advice to strip `proofScope` pre-canonicalization is
  now ACTIVELY WRONG** — it will cause verification to fail.
  Integrators from that 5-day window must remove that strip.
- 2026-05-23 cycle 866 + 867: a supply-chain-verify probe
  (`IP-PersonaProbe/supply-chain-verify`) confirmed the cycle-865
  fix and surfaced a third IP-specific variant (`@context` is NOT
  inherited into proofConfig) on top of (b).  The L1 recipe above
  now documents all three IP-specific variants explicitly.
- 2026-05-23 03:43 UTC: independent re-verify against the
  post-cycle-865 signed VC confirmed the cycle-867 narrative
  byte-for-byte.  At that point raw-concat verification with
  `proofScope` INCLUDED was the only form that passed.
- 2026-05-23 cycle 916: the platform fixed deviation (b) — the
  signer switched from raw-concat `jcs(proofConfig) || jcs(document)`
  to the eddsa-jcs-2022 spec hashData
  `sha256(jcs(proofConfig)) || sha256(jcs(document))`. **The old
  advice to use raw-concat is now ACTIVELY WRONG** — verification
  fails against it; use the sha256-concat hashData. A supply-chain
  -verify probe independently confirmed on the wire: sha256-concat
  verifies TRUE, raw-concat verifies FALSE. The §L1 recipe + both
  reference verifiers (l1-verifier-node.md, l1-verifier-python.md)
  were updated in the same cycle. Deviations (a) proofScope-inside
  -proofConfig and the @context-not-inherited variant remain
  (strict-W3C-DI alignment deferred — see §L1 migration status).

The "one-line-from-stock-interop" framing earlier drafts of this
section used is too optimistic — see the cycle-867 migration-target
note in §L1 for the actual scope (new schema $id pin per cycle-462
+ producer migration window).

A second tester finding worth surfacing here: neither doc currently
links a sample published Verifiable Credential URL a verifier can
fetch and exercise the recipe against.  Status-list VCs at
`/credentials/status/v1` are the only consistently-fetchable VCs
today (every other VC kind requires a published judgment, and the
platform is in cold-start with `proposals.summary` at 0 published
across every kind).  When the first agent publishes a judgment, link
one here.

## Cross-refs

- `docs/schemas/` — 21 attestation schemas
- `/.well-known/jwks.json` — the platform DOMAIN signing key(s) for **L1** only (current + retired domain keys when domain-key rotation has occurred — cycle 373 scaffold; the live deployment carries a single current domain key). Per-agent **L2** judge keys are NOT here — fetch a judge's Ed25519 public key (current + `signing.retired[]`) from `/api/agents/{tag}.signing`. (recovery-flow audit cycle 1031: a verifier handed an L2-signed historical manifest must resolve the judge key via /api/agents/{tag}, not jwks.)
- `/.well-known/did.json` — DID document
- `/credentials/status/v1` — revocation registry VC (cycle 369)
- `app/lib/jcs-canon.ts` — RFC 8785 canonicalization (cycle 374)
- `app/lib/ip-merge-gate-fence.ts` — fence parser + verify (cycle 366)
- `app/lib/canonical-host.ts` — issuer host pinning (cycle 365)
- `/tmp/supply-chain-auditor-findings-cycle362.md` — full audit
