docs · docs/30-core/CONSISTENCY_SUITE.md · synced 2026-09-10

The consistency suite

what 'measured' means on the landing: the checks, the anti-artifact checks, the scorecard

Purpose: define exactly how we prove a fingerprint config — and a built core — is coherent and undetectable enough to ship. This is the release gate referenced by CHROMIUM_BUILD_AND_UPDATE §5 and core/tests/consistency/. Detection comes from inconsistency between signals, not single values, so the suite tests coherence, not just presence.

1. What we test (three layers)

  1. Coherence assertions (ours, deterministic): internal contradictions → hard fail.
  2. External probes (third-party detectors): run headless against the core, score the result.
  3. Artifact checks: no JS-shim tampering artifacts (the thing that betrays cheap antidetects).

2. Coherence assertions (fail, don’t warn)

ID Check Rule
C1 UA ⇄ UA-CH both derived from same os+browser.version; no free-typed mismatch
C2 GPU ⇄ OS WebGL/WebGPU vendor+renderer plausible for OS (no Apple GPU on Windows)
C2b WebGL caps (measurement) RECORD the WebGL capability profile (numeric getParameter limits + getSupportedExtensions + shader precision) — Probe layer, non-blocking, characterize-only; makes the post-0020 capability residual visible instead of C2-green hiding it
C2c WebGPU readback (measurement) RECORD the FNV-1a hash of a WebGPU copyTextureToBuffermapAsyncgetMappedRange clear-color readback (read TWICE for determinism) — the raw-buffer path 0021’s canvas hooks miss (ISSUE-021b b). Probe layer, non-blocking, characterize-only: passes on stock (un-noised host-real) AND on the built core (patch 0024 seeded-noised), FAILs only on ok-but-UNSTABLE (a per-call-random readback). Un-noised baseline 5fc51dc5126031b4 under 0024; seed-sensitivity proven out-of-band by the two-seed gated test
C2d WebGPU non-corruption (guard) Adversarially PROVE patch 0024’s noise scope leaves NON-color readbacks bit-exact: a copyBufferToBuffer compute-staging read AND a color texture region OVERWRITTEN by a buffer copy (the taint path) both return the exact known pattern. Probe layer, non-blocking, characterize-only; passes on stock (no noise) + the built core (noise correctly scoped away), FAILs ONLY on ok-but-NOT-exact (0024 noise leaked past the format allow-list / taint onto compute data — a real corruption regression). The live guard of 0024’s “never corrupts compute data” invariant
C3 Fonts ⇄ OS v1 (patch 0023): a DEEP projection — BOTH halves of the native FontCache::CreateFontPlatformData gate: the config OS’s signature fonts report PRESENT (measureText/document.fonts.check) and the host’s other-OS signature fonts are HIDDEN. A host-absent config font is SUBSTITUTED to a distinct HIDDEN host face (seeded injective permutation); metric fidelity is a residual (ISSUE-023)
C3b Font metrics (measurement) RECORD each config-OS signature font’s measureText width (the SUBSTITUTE face’s, not the real target-OS font’s) + assert INJECTIVITY (no two distinct claimed fonts collide on a width) — Probe layer, non-blocking, characterize-only; makes the substitute-metrics residual visible instead of C3-green hiding it (mirrors C2b)
C4 Timezone ⇄ proxy v1 (patch 0014): a DEEP projection — observed Intl tz == config.locale.timezone (+ an offset range-rider); the proxy-egress-geo cross-check (Intl/Date tz reachable from the proxy exit IP) is a later network-layer residual
C5 WebRTC ⇄ proxy v1 (ADR-0027, network-INVERSION): a NON-deep coherence guard — the core IS raw Chromium M149, which hides local IPs behind an mDNS .local hostname by default (WebRtcHideLocalIpsWithMdns, on since M80), so it masks them BY CONSTRUCTION (nothing to spoof). C5 gathers the engine’s WebRTC ICE candidates IN-PAGE (host candidates need no STUN/TURN — they come from the local interfaces the moment setLocalDescription runs) and asserts no host candidate exposed a literal local-interface IP (any range — a raw address is a deanonymization tell whatever its class; srflx/relay reflect the STUN/proxy path, not the machine). The .local masking is checked for Chrome’s genuine random-UUID shape (8-4-4-4-12 hex); a non-UUID .local (a real machine hostname / bad fake) is recorded distinctly as MDNS-SUSPECT: — not an IP leak, but a core producing only suspect names fails the host:mdns gated assertion below (ISSUE-028 b). The classification is a tested pure-Rust helper (classify_ice_candidate), not JS (audit C-2). The observed candidate summary is RECORDED (like C10’s JA3), not equality-gated. PASS = “no leak” (the check is fail-OPEN — it only FAILS on a positively-parsed raw host address, so an empty gather / probe-error / webrtc-unavailable never false-fails, and no offline Signals builder can trip it via the Default). To stop a rotting probe passing invisibly, the gated live tests assert the POSITIVE signalc5.detail must contain host:mdns (a real masked candidate was actually observed) — so a disabled-mDNS / removed-WebRTC / broken-probe regression is caught where a real interface exists (audit ISSUE-028). Verified live: stock M150 + built M149 both give host:mdns:1. NON-deep (both providers pass). Catches a future --disable-features=WebRtcHideLocalIpsWithMdns (a raw host IP → fail). Deferred (a documented residual, like C4/C9’s proxy-egress-geo): the srflx/public-IP == proxy-egress leg needs a STUN server + a real proxy and rides the network/proxy layer
C6 Screen dpr × resolution + colorDepth is a real device combo
C7 Canvas/WebGL noise v1 (patch 0021): asserts STABILITY (NON-deep, ISSUE-021d) — a canvas double-read is deterministic (same hash), so per-call-random noise (the classic tell) is caught + blocks the verdict. This is a quality property, NOT a deep projection: it holds on stock too (stock reads identical bytes), so it is NOT .as_deep()/in the built deep_checks. Noise-PRESENCE vs stock (the differs-across-profiles / seed-sensitivity deep leg) is now PROVEN out-of-band by the two-seed gated test built_core_canvas_and_audio_noise_are_seed_sensitive (two built-core launches differing only in canvas.seed → different hashes, each still stable) — ISSUE-021b (a) CLOSED. C7 STAYS non-deep in the scorecard (stability holds on stock); the seed-sensitivity is a two-launch relational property, not a per-scorecard check
C7c Canvas premul cross-API (patch 0021 / ISSUE-021c a) NON-deep Coherence — a NON-opaque canvas read via toBlob vs toDataURL must decode to IDENTICAL pixels. The premul fix makes toBlob (canvas_async_blob_creator.cc) noise UNPREMUL straight-alpha pixels, exactly like the toDataURL path — before the fix toBlob noised the PREMUL backing, so the same canvas read two ways diverged (a cross-API tell) and a premul channel could exceed alpha (an impossible-premul tell). Holds on stock (no noise ⇒ trivially identical) AND the fixed built core with noise active; a negative diff is a measurement gap. Proven live on both providers
C8 Hardware cores/memory mutually plausible and matching device class
C9 Languages v1 (patch 0012): a DEEP projection — the observed ORDERED navigator.languages == the config’s locale.languages AND navigator.language == locale.language, plus the kept internal-coherence legs (language==languages[0], well-formed primary). Both halves are native: navigator.languages (Blink NavigatorLanguage, main frame + workers) and the Accept-Language header (browser ComputeAcceptLanguage, byte-identical to a real Chrome pref). Empty observed ⇒ NON-deep measurement fail. The JS list is asserted here; the outgoing header is measured by C9b
C9b Accept-Language header (measurement) RECORD the outgoing Accept-Language REQUEST header — captured server-side by the loopback echo page (JS can’t read its own document request’s header). Probe layer, non-blocking, characterize-only (like C2b/C3b): records the header (passes on stock + built), and the built gated test asserts it projects the config (en-US,en;q=0.9) — the network half of C9’s coherence, so navigator.languages and the header can’t silently diverge (patch 0012 / ISSUE-025 b)
C10 TLS fp ⇄ browser v1 (ADR-0027, network-INVERSION): a NON-deep coherence guard — the core IS raw Chromium M149 (ADR-0007), so its TLS ClientHello / JA3 is Chrome’s BY CONSTRUCTION (nothing to spoof). C10 captures the real ClientHello over a loopback https listener (a raw TcpListener — no TLS crate; the ClientHello is cleartext, pre-key-exchange) and asserts it is real-Chrome-shaped: GREASE present + TLS 1.3 offered + the Chrome ALPN (h2,http/1.1) + X25519 — NOT a Chromium-default / curl / TLS-terminating-proxy handshake. The JA3 string is RECORDED, not equality-gated (a golden JA3 is brittle across versions/GREASE/the per-connection extension shuffle — like C2b/C3b/C9b’s “no silent measurement”). The raw JA4 (FoxIO JA4_r, un-hashed) is ALSO recorded: unlike JA3 it sorts its cipher/extension lists, so it is STABLE across the per-connection shuffle (proven live — stock M150 + built M149 give byte-identical JA4 a/ciphers/extensions, differing only in sigalgs) → the version-stable, pin-able signal a future per-major gate can key on (ISSUE-027 f). The JA4 SNI bit reads i because the probe target is a loopback IP (Chrome sends no SNI to a bare IP). NON-deep (stock passes too — the engine IS Chrome), so NOT in any deep_checks set. The browser.version↔engine-major realism-prior is a CONFIG-time concern (validate.rs C10_VERSION_MALFORMED + the profile-launch pin against the active provider), not this frozen-fixture check
C11 HTTP/2 ⇄ browser v1 (ADR-0027, network-INVERSION): a NON-deep coherence guard (the twin of C10) — the core IS raw Chromium M149, so its HTTP/2 SETTINGS are Chrome’s BY CONSTRUCTION. C11 captures them over a TLS-terminating loopback h2 probe (rustls + a self-signed rcgen cert + h2 ALPN — the SETTINGS frame is INSIDE the TLS tunnel, unlike C10’s cleartext ClientHello; the browser gets the suite-only --ignore-certificate-errors), asserts real-Chrome-shape (SETTINGS_ENABLE_PUSH == 0), and RECORDS the FULL Akamai fp 1:65536;2:0;4:6291456;6:262144 | 15663105 | m,a,s,p — SETTINGS + WINDOW_UPDATE + the pseudo-header order (m=:method a=:authority s=:scheme p=:path, decoded HPACK-lite from the request HEADERS frame — the strongest per-browser discriminator), identical on stock M150 + built M149, MORE version-stable than JA4. NON-deep (both providers pass). QUIC/h3 → C13 (ISSUE-027 c)
C13 QUIC ⇄ browser v1 (ADR-0027, network-INVERSION): a NON-deep coherence guard (the 4th leg, after C10/C11/C5) — the core IS raw Chromium M149, so its QUIC is Chrome’s BY CONSTRUCTION. C13 forces the browser to open QUIC to a loopback origin (--origin-to-force-quic-on = LaunchSpec::force_quic_on, set BEFORE launch — a launch flag, unlike C10/C11’s post-launch navigations; Chrome otherwise only tries QUIC after an Alt-Svc: h3 hint a raw probe can’t send), captures the QUIC Initial on a loopback UDP probe (QuicInitialProbe — a raw UdpSocket, no handshake), and asserts the UNENCRYPTED long header (RFC 9000 §17.2; the top nibble of byte 0 is NOT header-protected, RFC 9001 §5.4) is real-Chrome-shaped: a long-header Initial + a recognized QUIC version (v1 0x00000001 / v2 0x6b3343cf) + an 8-byte DCID (Chrome’s connection-id length) + an empty first-flight token. The fp string version|dcid=..|scid=..|token=.. is RECORDED (like C10’s JA3 / C11’s Akamai fp), not equality-gated. NON-deep — verified live: stock M150 + built M149 BOTH emit an identical 00000001|dcid=8|scid=0|token=0 → both pass → characterize == Matches on both. An empty capture ⇒ the C13 gap branch (a non-deep fail flagged to investigate, like C10/C11), never the ceiling. v2 crypto foundation LANDED 2026-07-04 (quic_decrypt.rs, ISSUE-027 c): the QUIC Initial is protected with DETERMINISTIC, secret-free keys (HKDF-SHA256 from the DCID + a published salt, RFC 9001 §5.2), so any observer decrypts it — derive_client_initial_keys (HKDF) + header_protection_mask (AES-128-ECB, RFC 9001 §5.4) + decrypt_initial_payload (AES-128-GCM open), all pinned against the RFC 9001 Appendix A worked example (exact key/iv/hp + the HP mask) + a self-protected round-trip. v2b-1 ClientHello extraction LANDED 2026-07-04: client_hello_from_initials reassembles the inner TLS ClientHello from the captured Initial(s) — Chrome FRAGMENTS its ~1.7 KB ClientHello across multiple ~1250 B Initials (a single-packet decrypt fails on the common case — found by live testing, not assumed), so the probe retains ALL Initials and collect_crypto accumulates the CRYPTO stream by offset ACROSS packets (grouped by DCID; parses-past ACK/CONNECTION_CLOSE per RFC 9000 §12.4; capped for DoS-safety), then extract_client_hello returns only a COMPLETE handshake. Verified live: the built M149 core’s 6 captured Initials reassemble to a whole 1707–1737-byte ClientHello, 3/3 runs. v2b-2a QUIC-JA3 parse LANDED 2026-07-04 (quic_ja3.rs): parse_client_hello_fp walks the reassembled ClientHello (RFC 8446 §4.1.2) → a JA3 string (version,ciphers,exts,curves — GREASE-stripped per RFC 8701/9287) + the QUIC transport_parameters IDs (ext 0x0039, the QUIC-specific discriminator absent from a TCP-TLS JA3). Like C10, records BOTH the raw JA3 (order-sensitive → Chrome shuffles extensions) and a sorted-extension stable variant. Verified live: the built M149 emits 771,4865-4866-4867,10-13-16-27-43-45-51-57-…,4588-29-23-24,|tp=1-3-4-… — decoded to Chrome’s real TLS-1.3 ciphers + X25519MLKEM768 + ECH + 12 transport params; the sorted fp is IDENTICAL across runs (stable despite the shuffle). v2b-2b WIRED 2026-07-04 — QUIC now MEASURED to full depth (structure + JA3): score_running_core’s C13 capture waits for the ClientHello to reassemble from the captured Initial(s), then RECORDS quic_ja3 in Signals; the C13 check surfaces it in its detail (recorded, not gated — like C10’s JA3). Verified live on BOTH cores — stock M150 + built M149 record the IDENTICAL 771,4865-4866-4867,10-13-16-…-57-…,4588-29-23-24,|tp=1-3-4-… (Chrome’s TLS stack is version-agnostic here), characterize == Matches on both, 0 orphan. The QUIC leg is now as deep as C10 (TLS): structure + a distinct QUIC-JA3 (its 3 TLS-1.3 ciphers + transport_parameters differ from C10’s 14-cipher TCP-TLS JA3)
C12 Audio noise v1 (patch 0022): asserts STABILITY (NON-deep, like C7) — an OfflineAudioContext render (fixed graph: triangle osc → DynamicsCompressor) hashed TWICE from the same frozen getChannelData(0) buffer; a stable double-read proves a deterministic readback (stock, or the ±1e-5 stable seeded noise), a MISMATCH is a per-call-random tell. Not deep (stability holds on stock too); presence-of-noise / seed-sensitivity is now PROVEN out-of-band by the two-seed gated test built_core_canvas_and_audio_noise_are_seed_sensitive (the audio seed = canvas.seed under the "audio" domain, so two seeds → different audio hashes) — ISSUE-026 (c) CLOSED. B75 (2026-09-05): the fixture now carries media.audioContext: "coherent-noise". Since 0027 (2026-08-30) THAT field, not canvas.mode, enables the audio noise — and the frozen fixture never opted in, so for six days this row hashed the STOCK audio (d089798d, byte-identical to the installed 152’s) and called it stable, while the two-seed gated test had been red with nobody running it (#[ignore]). A stability check passes when the noise is OFF; only the seed test tells the difference, which is why it exists. With the switch: afd91acd on the built core (stock afd49768), stable ×3, and the seed test — its name today is built_core_canvas_audio_and_webgpu_noise_are_seed_sensitive — green on canvas + audio + WebGPU.
C12b Audio analyser dB range (measurement) RECORD the AnalyserNode.getFloatFrequencyData min (site B) — Probe layer, non-blocking, characterize-only; guards the [-1,1]-clamp regression class the offline C12 hash can’t see (a real dB spectrum has min < -1; a clamped one collapses to -1). Timing-independent (a silent analyser floors very negative). Caught + now gates the review-found float-freq clamp bug (patch 0022 / ISSUE-026)
C15 Voices ⇄ locale.languages v1 (B71, 2026-09-03, path B / ADR-0031): a NON-deep coherence measure (like C7/C12; NOT in any deep_checks) — can the host SPEAK what the profile claims? FAIL if no localService voice carries navigator.languages[0]’s exact tag (ISSUE-063’s second sentence, made a check), FAIL if the engine’s default voice is in another language FAMILY (B61: speak() with no voice lands on the browser process’s default, and timing reads the choice back — no list filter hides it); region drift of the default is RECORDED, not failed. Two zeros kept apart (empty-after-wait = UNKNOWN, pass; not collected = FAIL). Positive control: fr-FR claim on this host → red; en-US after Language.Speech~~~en-US → green (local exact true, default George en-GB same-family, exact false); en-GB → green. Measured B71: with the pack, the en-US fallback lands on Microsoft David (Δ1–6 ms, 4/4 runs, identity or not); before the pack it landed on George every run (B61). Tool: tests/harness/voices.mjs.

3. Anti-artifact checks (no shim signature)

ID Check
A1 Function.prototype.toString of patched APIs still returns native code (no [native code] loss) — probed across the full patched surface: navigator.* getters (0010/0012/0013), screen.* getters (0011), WebGL getParameter/getExtension (0020), canvas toDataURL/toBlob/getImageData (0021); a native (engine-level) patch keeps every one native, a JS/CDP shim of any one flips it
A2 No unexpected own-properties / getters on navigator, screen, WebGLRenderingContext prototypes
A3 No timing anomalies between spoofed API calls (engine-level, not JS-delayed)
A4 Object.getOwnPropertyDescriptor on spoofed props looks native
A6 Notification-permission coherence (GAP-permissions) — the canonical headless-Chrome tell: Notification.permission and navigator.permissions.query({name:'notifications'}).state DISAGREE (classically denied vs prompt) where a real browser keeps them coherent. A6 maps Notification.permission→the PermissionStatus.state vocabulary (defaultprompt, else identity) and FAILs on a mismatch; gap-FAILs an unmeasured probe (like A1/A2/A4/A5). Verified live: stock M150 + built M149 both report Notification.permission 'default' ⇄ permissions.query 'prompt' (coherent) — modern --headless=new fixed the old-headless incoherence → A6 passes, characterize == Matches. Guards a regression (a headless-mode / rebase flip reintroducing the split → A6 fails, the suite catches it)
A5 No automation tells (GAP-webdriver) — the launched profile does NOT betray that it’s a CDP-driven / automated core: navigator.webdriver is false (Chrome sets it true under --enable-automation / legacy --headless; the launch uses --headless=new + raw CDP, which do NOT) AND no injected driver residue ($cdc_*/cdc_* ChromeDriver globals, __webdriver_*/__selenium_*/phantom hooks on window/document). Gap-FAILs an unmeasured probe (like A1/A2/A4) so an offline Default can’t masquerade as validated; else FAILs only on a positive tell. Verified live: stock M150 + built M149 both report navigator.webdriver false + no CDP/driver residue → A5 passes, characterize == Matches. Guards a regression (a future --enable-automation / headless-behaviour flip → A5 fails, the suite catches it) — the project ships a Phase-5 CDP automation engine, so an unhidden webdriver flag would trivially bot-flag every automated run regardless of the identity spoof
A7 mediaDevices shape (GAP-media-devices) — a MEASUREMENT: enumerate navigator.mediaDevices.enumerateDevices() and RECORD the shape (device count-by-kind, label-presence, distinct-group count). FAILs only if the API is absent (a real Chrome always has it) or the probe didn’t run. A 0-device count is a classic bot tell in a HEADED context but EXPECTED headless (no hardware), so it’s recorded + visible, NOT gated (a headed-production 0-device gate + the Blink SPOOF half are deferred — like ISSUE-020/023 the caps/metrics values are dataset-blocked). Verified live: stock M150 + built M149 both enumerate 2 device(s): audiooutput=1 videoinput=1 (no audioinput, no groups, labels empty)--headless=new provides phantom devices (NOT the 0-device tell), recorded + visible; A7 passes, characterize == Matches. Recorded not equality-gated (like C2b/C3b)
A8 WebGPU adapter shape (GAP-webgpu) — a MEASUREMENT that surfaced a REAL residual: C2’s patch (0020) spoofs the WebGL vendor/renderer string, but WebGPU’s adapter.info + adapter.limits reflect the real host GPU unchanged — a cross-API incoherence a detector cross-checks. A8 RECORDS the adapter identity (vendor/architecture/description, often Chrome-masked) + a caps proxy (maxTextureDimension2D, feature count); gap-FAILs only an uncollected probe, else PASSes (visibility, not gated). Verified live: stock M150 + built M149 both report WebGPU ok: adapter 'nvidia/pascal' maxTex2D=16384 features=18 — the real Windows dev-host GPU. On STOCK, WebGL is unspoofed so WebGL⇄WebGPU AGREE (no leak); on the BUILT core, C2 spoofs WebGL→Apple-M3 while WebGPU still leaks nvidia/pascal — a live cross-API GPU incoherence, now recorded + visible. The SPOOF (a coherent WebGPU projection) is DEFERRED under ISSUE-020 (dataset-blocked — a synthesized Apple-M3 WebGPU caps body would itself be a realism tell, the same constraint as the WebGL caps-body). A8 passes, characterize == Matches
A9 navigator.plugins carries the fixed Chrome PDF set (GAP-plugins-mime) — modern Chrome ships a FIXED 5-entry PDF-plugin set + pdfViewerEnabled === true + PDF mimeTypes (real-by-construction, not spoofed). An EMPTY navigator.plugins is a classic headless/bot tell → A9 FAILs on it (and gap-fails an unmeasured Default, like A1/A2/A4/A5/A6); else asserts the coherence a real Chrome keeps (plugins present ⇒ pdfViewerEnabled + non-zero mimeTypes). Verified live: stock M150 + built M149 both report 5 plugin(s), pdfViewerEnabled=true, mimeTypes=2--headless=new carries the full PDF set (NOT the old-headless 0-plugins tell), A9 passes, characterize == Matches. A characterize-only regression guard (a rebase / headless flip that empties the set fails loudly)
A10 speech-voices shape (GAP-speech-voices) — a MEASUREMENT: speechSynthesis.getVoices() is an OS-correlated list (e.g. Microsoft * on Windows, Alex/Samantha on macOS), same class as the fonts leak (C3) — a spoofed-macOS profile on a Windows host leaks Windows voices. A10 RECORDS the shape (count + a name sample + distinct langs); gap-FAILs only an uncollected probe, else PASSes. Verified live: stock M150 + built M149 both report 0 voice(s)--headless=new has no TTS engine, so no OS voices leak in the headless suite (a HEADED production run would expose the host voices — recorded + visible for that gate). A 0-voice count is recorded, not gated (like A7’s headless 0-device); the SPOOF is a later Blink patch. A10 passes, characterize == Matches

These are why engine-level patching beats JS injection (Camoufox principle). A built patched core should pass A1–A4 by construction; a JS/CDP provider will not — that’s its documented ceiling.

4. External probes (scored)

Run headless (Playwright/CDP) against the core, capture + score:

  • CreepJS — trust score + lie detection (flags inconsistencies/overrides).
  • pixelscan — consistency + automation flags.
  • fingerprint.com / BotD-style — bot likelihood.
  • browserleaks (WebRTC, canvas, fonts, WebGL) — per-signal leakage. Each probe → normalized sub-score; record raw output as an artifact for diffing across versions.

5. Scorecard & gate

suite result = {
  coherence:   all C1..C9 PASS  (any fail → BLOCK)
  artifacts:   all A1..A4 PASS  (any fail → BLOCK)
  probes:      weighted score ≥ THRESHOLD  (else BLOCK)
  trust_score: provenance × validator × core_capability
}
  • Any coherence/artifact failure blocks release regardless of probe score.
  • Probe THRESHOLD is a tunable (OPEN question) — start strict, calibrate against a known-good core.
  • Output is machine-readable; CI keeps last-known-good core pinned until a new build passes.

Deep-capability set (baseline anchor). A provider declares which deep / projection checks it actually spoofs as a set of check ids (deep_checks, e.g. [] for stock, ["C8"] for the 0010 build) — replacing the old single deep_fingerprint bool. Baseline-characterization then expects each deep check in the set to PASS and every other deep check to FAIL (the documented ceiling — that signal isn’t patched yet). Because identity patches land incrementally (0010 navigator-basics → C8; WebGL/fonts → C2/C3 later), the set lets a partially-patched core be characterized correctly instead of all-or-nothing (ISSUE-019). A deep check that passes outside the set (a host coincidence) or fails inside it (a regression) is a DEVIATION.

⚠ C2 scope caveat (WebGL capability-profile residual, ISSUE-020). C2 today reads only the two WEBGL_debug_renderer_info strings (UNMASKED_VENDOR/RENDERER); patch 0020 spoofs exactly those. So C2-green does NOT mean “WebGL is coherent” — the numeric getParameter limits, getSupportedExtensions list/order, getShaderPrecisionFormat matrix, getContextAttributes, the WebGL2-only params, the WebGPU GPUAdapter, and the readPixels/pixel-hash all stay the host ANGLE backend (real D3D11 on the dev host) and can contradict the spoofed GPU string. C2b now MEASURES this — a Probe-layer check records the caps profile (MAX_TEXTURE_SIZE + MAX_*_UNIFORM_VECTORS + MAX_RENDERBUFFER_SIZE + a sorted-extension digest + highp-float precision) in every scorecard, so the residual is VISIBLE next to the spoofed string instead of hidden. C2b PASSES on a plausible profile and FAILS only on an UNAVAILABLE / GROSSLY-BROKEN context (empty extensions or MAX_TEXTURE_SIZE < 4096); it does NOT detect a SwiftShader software fallback — real SwiftShader reports plausible caps (16384 + a full extension set) and c2b runs post-spoof, so that software-renderer tell needs a caps-based discriminator (a later patch). Gate interaction: the Probe layer never blocks the Verdict, but C2b is non-deep so a C2b FAIL is a characterize deviation (a broken context is surfaced, not silently green). C2b does NOT judge caps-vs-config-GPU (needs a per-GPU reference dataset); a params/extensions patch (≈0024 — 0021/0022/0023 are canvas/audio/fonts) closes the caps half.

6. Per-provider expectations

Provider Expected suite result
chromium-stock (CDP/inject) C-checks mostly pass; A1–A4 likely FAIL → low trust, documented ceiling, not for high-stakes
camoufox/patched-base A-checks pass (engine-level); good probe scores
chromium-patched (ours) all C + A pass; probe ≥ threshold → ship

Measured baselines of the built core, by pin. M149 (149.0.7827.201, 2026-06-27 → 2026-09-04): pristine = Matches at the stock ceiling (empty deep_checks), patched = Matches with deep_checks = [C8, C6, C1, C2, C4, C3, C9]. M152 (152.0.7977.75, B74, 2026-09-04/05): the pristine build (out/M152, chrome.dll 2026-09-04 14:24:54) read through the stock declaration is Matches at the ceiling — all seven projection checks fail as stock does, every shallow check passes, Chrome/152.0.7977.75 — and its JA4 (t13i1516h2_002f,…,cca9_0005,…,44cd,ca34,fe0d,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601), Akamai (1:65536;2:0;4:6291456;6:262144|15663105|m,a,s,p) and QUIC-JA3 are byte-identical to the installed stock 152.0.7977.75 measured by the same instrument (JA3 differs only by Chrome’s per-connection extension shuffle). The patched build (chrome.dll 2026-09-05 02:24:38, the series of 21 on the 152 base) is Matches, 7 deep, Pass, all 32 checks with the same pass/deep as on M149, C15 in the card, and the same JA4/Akamai as the pristine one — the network inversion (ADR-0027) holds on 152 by construction. The 149→152 move is priced in SERIES_REBASE_COST.md (internal: docs/30-core/SERIES_REBASE_COST.md) §7. The M152 stock ceiling differs from M149’s in two shallow facts worth knowing: the ClientHello now carries ML-DSA signature algorithms (0904,0905,0906) and the Trust Anchor IDs extension (ca34) in code, not by field trial (B65 had measured ca34 as a stock-only difference on 149).

7. How it runs

  • Local: core/scripts/run-consistency against a built core → scorecard.
  • CI: gate on every core build (and on fingerprint-catalog changes) before publish.
  • Pre-launch (runtime): a fast subset (C1–C5) validates a profile’s identity before profile.start; failures flag the launch low-trust (FINGERPRINT_CONFIG_CONTRACT §6), never silently shipped.

8. Quiet mode — the headed probes without the windows (B68)

run-consistency never shows a window: the scoring suite is headless by design (§7). What appears on the operator’s desktop by the hundred are the headed launches — the integration tests’ harness (probe_fonts*, screen_metrics, headers_ua_ch, engine_baseline, …) and the headed self-characterization (capture_suite(…, headless = false)). All nine of those launch sites go through one helper, vflin_consistency::quiet::launch_headed, and that is where quiet mode lives.

Switch: on by default on Windows since B70 (2026-09-03); VFLIN_SUITE_QUIET=0 turns it off, =1 is still accepted; non-Windows: always off (focus guard is Windows-only, the occlusion feature is Chrome’s Windows tracker, and an off-screen window on X11/Wayland was never measured — CI on ubuntu launches as before). It became the default because B69 measured that without it the harness loses: both faces of ISSUE-148 are page-visibility conditions a hidden or occluded harness window triggers, and quiet mode keeps the page visible by construction — 160/160 on B67’s sample against 158/160 and 159/160. The product launch path (launch.rs) never sees any of this: the operator’s own profiles open on screen exactly as before.

What it does, each part forced by a measurement (worklog/core.md §B68):

part why measured
--window-position=-32000,-32000 create the real-size window off every display honoured, not clamped: page reads screenX -32000, first sight already off-screen
--disable-features=CalculateNativeWinOcclusion (merged into the harness’s existing --disable-features) a window on no display is visibilityState: hidden and rAF stops — a loss with it: visible, 10 rAF frames in ~140 ms, same as on-screen
move the window off-screen again whenever Chrome puts it on one the provider appends --window-position=0,0 (ISSUE-062) after extra_args; Chrome keeps the last value a WinEvent hook plus a 20 ms poll; each push-back counted
hand focus back (AttachThreadInput + SetForegroundWindow, confirmed by reading the foreground) Chrome activates its window on creation, off-screen or not taken on 458 of 460 launches, returned on 415, median ~65 ms; refused in bursts in 5 of 20 runs (budget 1.5 s). Operator’s-eye: foreground held by a harness window ~1 % of the time vs ~30 % in normal mode

What it must not change, and does not: window size (outerWidth/outerHeight = the claimed available area, ISSUE-062 — the provider sizes it, quiet mode only positions it), visibilityState, rAF cadence, and every check. Measured on the built M149 core: 20 of 20 quiet runs of the C6 instrument byte-identical to the normal baseline on every screen/avail/outer/inner row; the whole headed Signals object differs between modes by nothing beyond its own noise floor (observed_ja3); run-consistency 31 of 31 checks identical, 7 deep, Pass in both modes.

What it cannot fully hide, stated as measured: a flash on ~41 % of launches (one to three frames: the window is created at 0,0 by the provider and moved at first sight — creating it off-screen instead changes innerWidth by 16 px and is refused), and focus refusals in bursts (5 of 20 runs keep the foreground for up to 1.5 s on a few launches). In sampler terms: a harness window visible in ~5 % of samples (normal mode ~32 %).

Every quiet launch prints one [quiet] … line naming what happened (moved before/after shown, push-backs, focus taken/returned or never taken), so a series can be audited from its log. A failure to launch still answers under B67’s names (launch-port-timeout, cdp-connect-refused, …) — quiet mode adds no new failure kind.

Open questions

Probe sources stability/ToS, exact THRESHOLD, headless harness (Playwright vs raw CDP), how to run probes against a not-yet-published core, trust-score formula → OPEN_QUESTIONS.md.