VFLIN

docs · docs/30-core/CONSISTENCY_SUITE.md · synced 2026-11-08

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 copyTextureToBuffer→mapAsync→getMappedRange 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 5fc51dc5 → 126031b4 under 0024; seed-sensitivity proven out-of-band by the two-seed gated test · Provenance (B76, ISSUE-188): the noise applies only to bytes a copyTextureToBuffer of an 8-bit colour texture actually put in the buffer, and only when that copy’s submission RAN. Every buffer command the encoder records notes the GPUBuffers it names (copy source and destination, copyBufferToTexture source, copyTextureToBuffer destination, clearBuffer, resolveQuerySet); at submit() each referenced buffer is asked one question — will Dawn perform this submission? (mapped or map-pending: no; a consumed or known-invalid member or a duplicate: no) — and a rejected submission applies NOTHING it recorded, neither a region nor a taint, so a host-filled buffer a rejected copy named reads back bit-exact; queue.writeBuffer asks the same question of its target (a rejected write — mapped, unaligned, out of bounds, zero-length — taints nothing, so it cannot switch the noise off). B75 asked it only of provenance-bearing buffers, a list that grew a sibling per review (mapped destination, out-of-bounds, unaligned); B76 asks every referenced buffer instead. Measured before → after on the built 152 in worklog/core.md (B76). The question is asked of every buffer any CONTAINER names, since B80 (ISSUE-190). B79 measured what the mirror still missed: a buffer the submit reached only THROUGH A PASS (setVertexBuffer, a bind group) was referenced by no command the encoder recorded, so Dawn rejected the submit and we applied the copy anyway — a pre-filled destination came back with 95 of 95 colour bytes changed where stock changes none, which is our noise over the page’s own data rather than a pristine reading. A pass now carries its parent encoder and every naming command registers there (setVertexBuffer / setIndexBuffer, setBindGroup on either pass type, the indirect-draw buffers), and a RENDER BUNDLE’s references travel with the bundle from its own encoder through finish() to executeBundles. WebGPU has exactly four objects that record commands, so no container can name a buffer unseen; seven sequences measured 95 → 0 each, and webgpu_submit_classifier.rs is green. What that does NOT close is the list of Dawn’s rejection CAUSES — the mirror is still a denylist, the complete decider is Dawn’s own QueueBase::APISubmit, and ISSUE-190 keeps it as a priced post-alpha slice. The same gated file pins the page-visible error transcript (popErrorScope type and message, uncapturederror count) as identical between the identity and the control, and holds why the two Blink-side answers are refused: an error scope is B77’s A1/A2/A4 tell, and the uncaptured-error path sees 0 of a rejected submit the page wrapped in its own scope.
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 signal — c5.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. B83 built both on loopback — core/scripts/udp-proxy-stand.mjs is a SOCKS5 server with UDP ASSOCIATE and a STUN responder whose relay port IS a known exit address — so the leg no longer needs a real exit; and B84 BUILT the leg: crates/vflin-consistency/tests/c5_udp_via_relay.rs raises that stand and drives the built core under patch 0030 — with the profile’s network.udpRelay the page’s srflx carries the EXIT’s socket (host :49372 vs srflx :49374, the stand’s exit port) and no candidate carries a raw local address; an exit that refuses UDP and a non-loopback relay point each yield NO reflexive candidate rather than the old one. Gated (it needs node + VFLIN_BUILT_CHROME), non-deep, and it is the proxy-egress leg C5 had deferred. B90 (ADR-0043, patches 0040–0042) — what C5 does NOT see, and where it is measured instead: in any datagram mode the .local names are still CREATED (so C5’s host:mdns holds unchanged — re-run on the series-31 build) but no longer ANNOUNCED on the LAN, and a page-supplied REMOTE .local candidate is not looked up; in the substitute mode the page’s srflx is the exit’s address answered on this machine. None of that is a page-visible value C5 could assert, so it is gated OUTSIDE the suite by core/scripts/udp-modes-gate.mjs — ETW per core process (an elevated shell), five legs with a positive stock control, and a verdict per leg: 0 UDP to the wire and 0 DNS in the substitute and closed legs
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 · Both routes (B78, patch 0034, ISSUE-174): the WebGL2 PIXEL_PACK_BUFFER route — readPixels(…, offset) into a bound PBO, read out by getBufferSubData — carries the SAME mask and key as the direct readPixels (a readback region on the WebGLBuffer, applied over the bytes a read intersects and carried by copyBufferSubData), so one canvas has one picture whichever route reads it. A write into the buffer never drops a region — it takes back the bytes it WROTE (B79, ISSUE-194): taken bytes are an exact bitmap, and a write is a LAYOUT rather than a byte span, so a packed read leaves the PACK skip and the inter-row padding alone, a non-projected read takes its own image and not the rest of the buffer, and a transform-feedback draw takes the range bound at its index; the alpha byte decides the alpha-0 skip only while it is still the readback’s own. Pinned by TWO gated tests in webgl_pbo_noise.rs — b78_webgl2_pbo_route_reads_the_same_noised_pixel (direct == PBO pixel-for-pixel, both noised; a user-framebuffer read pristine; GL error codes as stock) and b79_pbo_exclusion_takes_the_write_footprint_and_nothing_more (eight write sequences that each left 13–757 colour bytes pristine before round 3, and a GL_INVALID_VALUE stock never raises, all 0 after). C7’s own hash is the direct route’s and did not move.
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 Since B97 (ISSUE-299) C7c also FAILS when an 8×8 canvas of 64 opaque colours painted with 1×1 fillRects (a fixed sequence) does not read back exactly through 1×1 getImageData — CreepJS’s getPixelMods (“pixel data modified”), a stock invariant on any host. Patch 0053 gives a canvas under 16 px on a side no noise. Red on a pre-B97 core (59 of 64), green on the B97 core.
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 ICU leg (patch 0016): Intl.DateTimeFormat/NumberFormat/Collator locales must agree with languages[0] — and “agree” is what Chrome resolves (B180, 2026-10-31): the leg used to demand the exact tag and had only met the en-US fixture; stock Chrome 156 on --lang=fr-FR answers Intl "fr" (as on it/pl/nl/ru/tr/uk/sr/hr/th/es/de), so a product profile on a French exit deviated on C9 while matching stock. It now accepts the exact tag, its bare language, or a measured region group (en-AU/en-CA → en-GB, es-MX/es-AR → es-419, zh-HK → zh-TW), and still deep-fails the host’s en-GB under en-US or es-ES (c9_accepts_the_locale_chrome_resolves_and_still_fails_the_hosts). And a language Chrome ships no interface for (B193, ISSUE-810): the launch starts it in machine_locale::NO_INTERFACE_FALLBACK (en-US) and stock Chrome’s Intl follows the interface (measured, the bare series-88 core: --lang=en-US --accept-lang=sq-AL,... and mt-MT,... both give Intl en-US), so the leg accepts exactly that interface for exactly those claims (interface_switch(locale.languages) == NO_INTERFACE_FALLBACK) and still fails the machine’s en-GB, Vision’s en and en-US under a language that has an interface (c9_accepts_the_interface_a_language_without_one_is_launched_in). Before it every profile of the 17 countries whose language that is read block. A11 beside it, same round: a language Windows ships no voice for (speech_voices::local_voices_for -> empty: Albanian, Maltese, Armenian, Georgian, Ukrainian …) with none listed by the host passes with that said – every real machine with the language is in the same state; a language Windows has a voice for, missing, stays red (a11_passes_a_language_no_machine_has_a_voice_for_and_still_fails_one_it_has)
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). B90 (ADR-0043 Decision 2, patch 0042): C13 measures the STOCK path — no proxy, QUIC forced to a loopback origin — and its record is unchanged on the series-31 build. The new path, QUIC through the profile’s datagram relay behind one SOCKS5 hop (network.udpRelay + network.quicViaRelay), is gated outside the suite by core/scripts/quic-relay-gate.mjs: an aioquic HTTP/3 origin at a name no resolver here can answer, the page must arrive over h3, every datagram must reach the relay NAMED (ATYP 0x03), none may reach the wire, no DNS may be asked for the origin — and the relayed ClientHello must reduce to exactly the direct leg’s JA4 ingredients (version, sorted ciphers and extensions, sigalgs, groups, ALPN, QUIC transport-parameter ids). The path moves; the engine does not
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) Since B97 (ISSUE-299) C12b also FAILS on two invariants stock holds on any host and CreepJS reads: an oscillator at 0 Hz renders exact zeros (two renders, 100 samples each — hasFakeAudio, “audio is fake”) and getChannelData equals copyFromChannel over one rendered buffer. Patch 0052 keeps silence at zero under the noise. Red on a pre-B97 core (200 non-zero samples), green on the B97 core.
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. B91 (ISSUE-235) — judged by what the HOST can speak, not by the claim. The en-US pack left this host again (OneCore holds only George/Hazel/Susan, en-GB), and C15 then blocked every card on a machine fact no patch can change. Now: red when the host has no localService voice in the claimed language FAMILY at all, or its default voice is in another family (the positive control stays: fr-FR on this host is red); green with WARN -- leading the detail when the family is there but not the exact tag — an en-US claim on an en-GB host, this host today. Remote voices (patch 0044’s Google voices, localService: false) never count as the host speaking. Unit tests: the warning case, three red cases, the green-after-pack case, the two zeros. B180 (ISSUE-756): the leg that can turn it red is now a command, not a memory — the frozen fixture claims en-US, the language this host’s voices speak, so run-consistency alone could only ever pass C15. run-consistency 157.0.8103.15 --config <a product profile on a foreign exit> is the red leg: on this host fr-FR, es-ES and pt-PT profiles deviate on C15 and on nothing else, en-GB matches (docs/30-core/SAME_MACHINE_CENSUS.md). B183 (patch 0098): a host with NO local voice at all passes — a real Windows without a voice for its language (the owner’s laptop, stock Chrome), and what 0098 shows a French profile on this English host; the default is then a remote voice, and the new field speech_default_local (an old capture reads as before) keeps a REMOTE default from failing the family rule. Still red: local voices present and none of the claim’s family; a LOCAL default of another family; the same French profile on a core without 0098. Test c15_passes_a_machine_with_no_local_voice_and_still_fails_a_wrong_local_default

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 (default→prompt, 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). The SPOOF half since B96: patch 0051 media-devices projects the list onto media.devices in the browser process (enumeration and capture together); its proof is the gated test tests/media_devices_projection.rs on Chrome’s fake devices (GREEN on the core, RED on stock) — A7 itself is unchanged, the frozen fixture carries no media.devices
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: VFLIN_BUILT_CHROME=<...>/chrome.exe cargo run -p vflin-consistency --bin run-consistency -- <version> → scorecard. The version argument is the CLAIM to score against; without it the version comes from the persisted config, not from the binary, so an env-selected core can be scored against someone else’s declaration (B83 printed 149.0.7827.201 while running the M152 build).
  • 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.

9. The stock leg over a DevTools port (A119, W30)

Stock Chrome turns the automation tell on for an EPHEMERAL DevTools port — --remote-debugging-port=0 is the shape ChromeDriver launches with, and Chromium keys AutomationControlled on it (our core stops that with 0035; stock keeps it). Measured A119 on the installed stock 153.0.8010.53, --headless=new, a fresh profile per leg, read with Runtime.evaluate and no Runtime.enable:

stock launch navigator.webdriver (page)
--remote-debugging-port=0 true
--remote-debugging-port=9347 (a named port) false
--remote-debugging-port=0 + --disable-blink-features=AutomationControlled false
--remote-debugging-pipe (B98, headed, on /lab/) true
a free port named on the command line (B98, headed, on /lab/; CreepJS “0 % headless”, fingerprint.com bot: not_detected — over port=0 the same page reads 33 % and bad) false

(A dedicated worker reads undefined in all three — workers carry no webdriver.)

The rule. A stock CONTROL leg read by anything that looks at automation — a detector page, the suite’s A5 — is never launched on port=0 alone — nor on the pipe alone, which raises it too (B98). It takes a named port, the provider’s counter-flag (headless only: headed, the flag hangs Chromium’s kBadFlags bar and shortens the viewport), or a plain launch with an injected reporter (W30’s method). A reading that did not is labelled “via port=0: webdriver true” and is not a stock baseline for automation tells. A probe that measures something webdriver does not move (fonts, voices, the audio sum) is not affected.

Where the legs stand. The Rust suite (run-consistency, quic_probe) reads stock over port=0 WITH the counter-flag — chromium_command gives it to every core that does not keep the tell off itself, on the port as on the pipe, and launch.rs’s test now holds the port leg to it — so A5 reads false, as the product does (the product launches over the pipe with the same flag). core/scripts/detector-visit.mjs since B98 (ISSUE-303) launches every leg on a free port it names (--transport named, the default), with no counter-flag — headed, the flag would hang the bad-flags bar and shorten the viewport; --transport pipe|port0 remain only to reproduce an old reading. Our core keeps the tell off on every transport (0035).

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.