Internal-only diagnostic doc. Not part of the public surface; not a normative spec; not linked from the front door. Stability classification is captured in docs/STABILITY-CONTRACT.md. Threat-coverage entries are captured in docs/THREAT_MODEL.md.
v0.13.2 adds a shadow-mode diagnostic foundation for comparing the protocol pointer-fetch path against a workspace-private resolver composition layer on the same input pair, without changing public protocol or wire behavior.
The foundation provides:
- A lazy-import boundary that loads the workspace-private resolver composition layer only when
PEAC_INTERNAL_SHADOW_RESOLVER=1. - Bounded normalization shapes so two implementations with different native taxonomies can be compared like-for-like.
- A redaction-safe in-memory ring buffer (
shadow-mismatch-sink) capped to[64, 16384]entries with a~512-byte JSON-stringified cap per entry. - A pure-function parity verdict computer that tags each mismatch into a small set of public classes.
- A no-network parity smoke that drives both implementations through inputs they reject before any network I/O is attempted.
- No live Hosted Verify route shadowing. The
apps/api/v1/verifyroute receives an inline compact JWS receipt; it does not accept a pointer URL or expected digest. Pointer-fetch is caller-side today: callers dereference pointer URLs and pass the resulting JWS into/v1/verify. There is therefore no primary-path pointer-fetch result to capture for shadow comparison without introducing brand-new network behavior on the verify route. - No internal mismatch endpoint. A read-only viewer endpoint (
GET /__internal__/shadow-mismatches) is intentionally omitted because the sink has no live producer in this version; an empty endpoint adds review surface without operational value. - No
packages/protocolsource change. No diagnostic capture hook is added; that pattern is reserved for a future change. - No public API, wire-format, OpenAPI, or publish-manifest change.
A live Hosted Verify route integration would have to either:
- Run protocol's pointer-fetch as a brand-new behavior on a route that has none today, then run the workspace-private resolver alongside as a "shadow." This would be a new feature, not shadow observation, and would double-fetch upstream pointer URLs. The diagnostic foundation's "no double-fetch" invariant rejects this.
- Wait for a primary-path pointer-fetch result to capture from a route that already does pointer-fetch. No such route exists in
apps/apias of v0.13.2.
Live route shadowing is therefore deferred to a future change contingent on either:
- A new Hosted Verify pointer-input feature that legitimately exercises pointer-fetch on the verify path (route would surface
pointer_url+expected_digestin the request shape), OR - A protocol diagnostic capture hook (callback or telemetry channel exposed by
@peac/protocol) that surfaces pointer-fetch outcomes from any caller for observation, without requiring the observer to invoke pointer-fetch a second time.
Until one of those exists, this foundation supports offline parity comparison only.
The sink records entries with the following class field. Parity classes capture cross-implementation drift detected by the verdict computer; the remaining classes are reserved for future producers.
| Class | Meaning |
|---|---|
parity_class_mismatch |
Protocol path and the workspace-private resolver path normalize to different NormalizedPointerClass values for the same input. |
parity_digest_mismatch |
Both implementations succeeded but reported different actualDigest values. Stop-the-line if observed in production. |
parity_content_type_warning_mismatch |
One implementation surfaced a content-type warning class and the other did not. |
parity_success_shape_mismatch |
Both succeeded but the public success-shape keys (actualDigest, contentType presence) differ. |
output-byte-diff |
Reserved. Future producer for raw-output byte divergence under a future capture hook. |
error-code-diff |
Reserved. Future producer for native-error-code divergence under a future capture hook. |
timing-diff |
Reserved. Future producer for cross-implementation timing-bucket divergence. |
resource-limit-diff |
Reserved. Future producer for resource-limit-class divergence. |
cache-hit-diff |
Reserved. Future producer for JWKS cache-hit divergence. |
cross-runtime-drift |
Reserved. Future producer for cross-runtime drift. |
Each sink entry is bounded to ~512 bytes when JSON-stringified. Strict redaction: no raw URL path or query, no headers, no body bytes, no excerpts beyond the bounded excerptLegacy / excerptShadow fields (<=128 bytes each, only set for output-byte-diff). No bearer tokens, cookies, private key material, or secret-shaped values reach the sink.
ts ISO 8601, second precision
requestHash sha256 hex of normalized request signature (<=64 chars)
class one of the classes above
legacySummary bounded { ok, code? <=64, byteCount?, jwksKid? <=32, durationBucket? }
shadowSummary same shape as legacySummary
excerptLegacy? <=128 bytes; only for output-byte-diff (no live producer yet)
excerptShadow? <=128 bytes; only for output-byte-diff (no live producer yet)
When an entry would exceed the ~512-byte cap, the sink degrades progressively: clamp string fields to per-field caps, then drop excerpts, then replace summary codes with the placeholder truncated, then fall back to a minimal entry (ts, class, ok flags).
The buffer is a fixed-capacity in-memory ring. Default capacity 1024 entries. PEAC_INTERNAL_SHADOW_BUFFER_SIZE is read once at first use and clamped to [64, 16384]. When the buffer is full, the oldest entry is overwritten; entries are never persisted to disk and never sent over the network.
In clustered deployments each Hosted Verify instance has its own ring buffer; no cross-instance aggregation is built or planned for this foundation.
Once a real producer is wired (live route integration, protocol diagnostic hook, or both), entries can be inspected via the in-process getter getMismatches() exported from apps/api/src/lib/shadow-mismatch-sink.ts. This foundation ships no remote retrieval surface.
When investigating drift:
- Group recent entries by
class. Anyparity_digest_mismatchis a stop-the-line bug; both implementations should compute the same SHA-256 of the same UTF-8 decoded body. parity_class_mismatchtypically points to a taxonomy gap inapps/api/src/lib/shadow-classify.ts. Inspect the input that produced the mismatch (viarequestHash) and whether the classifiers map a new error code or reason from either implementation.parity_content_type_warning_mismatchandparity_success_shape_mismatchtypically reflect implementation differences that are operationally benign but worth tracking. They may inform future doctrine clarifications.
This document does not specify any normative protocol behavior. It does not commit to wire formats, public APIs, or operational SLOs. It is a maintainer-facing diagnostic note about the shadow-mode foundation shipped in v0.13.2.
docs/STABILITY-CONTRACT.md— internal-only flag declarations and the shadow-mode timeout guarantee class.docs/THREAT_MODEL.md— shadow-mode telemetry threat and mitigation entries.docs/specs/RESOURCE-LIMITS.md— verifier resource limits (informative for future shadow-class producers).