Skip to content
New kind of attack — caught and fixed before it was publicly known·Read the timeline
Probity Open Predicate Standard
/predicate/v1/vocabulary-registry
View raw .md →

Probity Vocabulary Registry, v1

This document is the versioned public registry of the closed enumerations used by the Probity Predicate Standard. The enumerations are layer, violation_type, and sink, used by the catch-record component (and, transitively, by the verdicts that reference catch records), plus the auth_downgrade_cause enumeration used by the a2a-mesh-scorecard. These vocabularies are not free text. A conforming producer emits only registered values; a conforming verifier rejects any value not in the registry version it knows.

The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as in RFC 2119.

Why a registry, and why fail-closed

A security verdict's evidence is only meaningful if the vocabulary it uses is fixed and agreed. If a verifier silently accepted an unknown layer or violation_type, a producer could emit a value the verifier cannot interpret and the verifier would have no honest basis to trust or distrust it. So the registry is fail-closed at a version:

  • A verifier MUST reject any layer, violation_type, or sink value that is not present in the registry version it implements. There is no warn-and-continue mode, and none is planned.
  • This is the one place where the standard's general "ignore unknown" rule does NOT apply. A verifier ignores an unknown field (forward-compatibility), but MUST reject an unknown enum value for a known field. An unknown field is additive data the verifier can safely skip; an unknown enum value is a value the verifier cannot interpret and therefore cannot trust.

Registry versioning and governance

  • The registry version is tied to the predicate version. The v1 registry below is the vocabulary for the v1 predicates. A registry revision moves in lockstep with the predicate's compatibility rules.

  • Additions are registry revisions, not silent changes. Adding a value to any of these vocabularies is a published registry revision that ships a new registry list. It is never a silent change a deployed verifier is expected to tolerate; producers and verifiers adopt the new registry version together. A renamed or removed value is a breaking change and is a new major version.

  • Governance / contribution process. Probity maintains this registry initially. Proposals to add or change a value go through a published contribution process: a CONTRIBUTING document describes the request format, and changes are submitted as pull requests and reviewed in the open before a new registry version is cut. The intent is to graduate registry maintenance to a neutral, multi-party process as adoption grows. Until then, all proposed changes are reviewed publicly.

v1 registry contents

layer

The substrate-qualified layer that fired a catch. A value names a public abstraction of an isolation or policy layer; it does not expose any internal implementation detail. The v1 registered values are:

substrate.vfs_hook
policy.idna
policy.scrub_encodings
policy.authorize_context
policy.network_hook
policy.engine
netconfig.dns_pin
netconfig.dns_ad_quorum
sandbox.firecracker_isolation
sandbox.overlay_limits
vet.idna_recheck
vet.ide_settings_recheck
policy.egress_sinkhole
netconfig.nftables_moat
vmi.attribution
quarantine.coverage

substrate.vfs_hook names the guest filesystem hook engine: the substrate-level interception point that resolves every stat, readdir, open, create, mkdir, chmod, remove, and remove_all request a guest issues against a HookAction (an ActionFunc result or a static rule) before it reaches the real filesystem. A catch at this layer is a filesystem-syscall-shaped decision, which is what separates it from every other substrate/policy layer: policy.network_hook decides whether a network request or response may proceed, not a filesystem operation; policy.egress_sinkhole captures a stream that was already admitted past the network layer; vet.idna_recheck is a static-analysis finding emitted by a linter pass, not a runtime block at all. Only substrate.vfs_hook covers the guest's file-operation surface, so a reader can trust that a substrate.vfs_hook catch names a filesystem decision and nothing else.

policy.idna names the runtime hostname-canonicalization gate NormalizeHost applies to every policy pattern and every SNI/host header value the engine evaluates: IP-literal pre-reject, UTS #46

  • IDNA 2008 encoding, the CVE-2024-12224 pre-encode integrity check, RFC 1035 label-geometry validation, and the post-IDNA round-trip check. Every IDNA_* violation type in this registry fires under this one layer. It is distinct from the similarly-named vet.idna_recheck, which is a build-time static-analysis finding about caller code that skips a DIFFERENT IDNA hazard (missing a post-ToASCII net.ParseIP recheck) and never fires at runtime; and from netconfig.dns_pin, which enforces a DNS-response pin match strictly after a hostname has already survived policy.idna normalization. Collapsing any of the three would blur a build-time linter finding, a runtime normalization rejection, and a post-resolution pin mismatch into one undifferentiated bucket.

policy.scrub_encodings names the secret-scrubbing pass SecretScrubber.ReplaceAll / ReplaceAllString runs over an HTTP response body or SSE data line before it leaves the interceptor; the layer fires only when the pass actually replaced bytes (output differed from input), and it never records the raw secret material, only that a replacement happened. It is distinct from policy.network_hook, which governs whether the surrounding request or response was allowed to proceed at all — a scrub can fire on a request policy.network_hook already approved, because approval and content-rewriting are separate decisions; and from policy.egress_sinkhole, which captures and drops a stream wholesale rather than rewriting bytes within one that is still being forwarded. A scrub event is evidence of an exfiltration ATTEMPT inside allowed traffic, not evidence that the traffic itself was blocked.

policy.authorize_context names the dialer-family host-admission gate: the phase-ordered AuthorizeHostname -> FilterPinned -> VerifyDialedAddr sequence that decides whether a resolved destination may be dialed at all, and it carries every HOST_NOT_ALLOWED, SSRF_ATTEMPT, PIN_MISS, and PIN_IP_MISMATCH catch record this vocabulary defines. It is distinct from policy.network_hook, which evaluates an already-admitted connection's request/response shape (method, path, host glob) against the L7 rule engine — a destination can pass policy.authorize_context and still be blocked at policy.network_hook moments later; and from netconfig.dns_pin, whose pin table binds a TLS certificate's SPKI to Quad9, a wholly different pin than the resolved-IP PinTable this layer enforces. Reusing netconfig.dns_pin for a resolved-IP mismatch would misrepresent a dialer-level admission refusal as a DNS-resolver-level certificate rejection.

policy.network_hook names the L7 rule engine that matches an in-flight HTTP request or response against a network-hook rule's host glob, HTTP method set, and path pattern, blocking a request or response shape rather than a destination. It is distinct from policy.authorize_context, which decides whether the underlying destination may be dialed at all, upstream of any request being formed; a network-hook rule fires only after a connection has already cleared authorize-context admission. It is also distinct from policy.egress_sinkhole, which — in a sinkhole deployment — captures a request that has already cleared BOTH policy.authorize_context and policy.network_hook and would otherwise have been forwarded; a policy.network_hook block means the request never reached that point at all.

policy.engine names the umbrella policy Engine type in pkg/policy that owns and coordinates the IDNA, scrub-encodings, network-hook, authorize-context, and egress-sinkhole sub-components as a single construction/configuration unit. Unlike every sibling policy.* layer, it has NO production emit site today: every actual policy-layer catch record is tagged with the specific sub-component that fired (policy.idna, policy.scrub_encodings, policy.network_hook, policy.authorize_context, or policy.egress_sinkhole), and no code path in the engine's own construction or dispatch logic emits a catch record tagged policy.engine directly. It is registered so a future engine-level failure — one attributable to none of the specific sub-layers — has a name to emit under without a registry revision, but as of this version it names a reserved value, not an observed catch.

netconfig.dns_pin names the Quad9 SPKI certificate-pin enforcement on the DoT (DNS-over-TLS) upstream handshake: the VerifyPeerCertificate, VerifyConnection, and VerifyECHRejection closures and the shared enforce decay-window logic that together produce every QUAD9_PIN_MISMATCH, QUAD9_PIN_EMPTY_CHAIN, QUAD9_PIN_ALL_MALFORMED, and ECH_REJECTION_UNEXPECTED catch record. It is distinct from the resolved-IP PinTable enforced under policy.authorize_context (PIN_MISS, PIN_IP_MISMATCH) — that pin binds a hostname to the IP addresses it is allowed to resolve to, this pin binds the Quad9 resolver's own TLS certificate to a known SPKI hash, and the two mechanisms share no code path. It is also distinct from netconfig.dns_ad_quorum, which corroborates a DNSSEC AD-bit answer across independent resolvers rather than pinning any single resolver's certificate; the two layers defend against different adversaries (a compromised Quad9 cert versus a compromised or BGP-hijacked primary resolver) and neither can substitute for the other in signed evidence.

netconfig.dns_ad_quorum names the cross-resolver DNSSEC AD-bit corroboration gate: when the primary resolver (Cloudflare DoH, CA-only validated) returns an AD=1 answer, this layer fans the same query out to independent peer resolvers (NextDNS, Mullvad) within a bounded window and requires at least one to corroborate the primary's answer digest before the chain will surface it, producing every AD_QUORUM_MISMATCH, AD_QUORUM_TIMEOUT, and AD_QUORUM_ALL_PEERS_ERRORED catch record. It is distinct from netconfig.dns_pin, which pins a single resolver's own TLS certificate rather than cross-checking that resolver's answer against independent peers — a compromised primary with a perfectly valid certificate would pass netconfig.dns_pin but fail netconfig.dns_ad_quorum the moment no peer corroborates its forged answer. The two layers are deliberately independent gates on the same DNS path, not variants of one mechanism, so a verifier can tell which specific defense caught an attack.

sandbox.firecracker_isolation names the Firecracker microVM isolation boundary itself — the hypervisor-level containment the sandbox package constructs around a guest. Unlike its sibling sandbox.overlay_limits, which has a live production emit site guarding the overlay-disk layer/device budget on the same VM-construction path, sandbox.firecracker_isolation has NO production emit site today: no code path in pkg/sandbox currently emits a catch record tagged with this layer. It is registered as the intended home for a future isolation-boundary catch — a hypervisor-level containment failure distinct from the disk-layout preconditions sandbox.overlay_limits already guards — but as of this version it names a reserved value, not an observed catch.

sandbox.overlay_limits names the overlay-disk layout precondition check validateOverlayDiskLayout runs before a Firecracker microVM is launched: it enforces the 20-lower-layer budget and the 24-block-device Firecracker ceiling (root + lowers + upper + extra), producing OVERLAY_LAYER_LIMIT and OVERLAY_DISK_LIMIT. It is distinct from sandbox.firecracker_isolation, the sibling layer name reserved for the hypervisor isolation boundary itself rather than the disk-layout precondition that must hold before that boundary is even constructed — sandbox.overlay_limits fires and prevents the guest VM's exec.Command launch from ever happening, so no isolation boundary exists yet to have failed. Keeping the two names distinct preserves the fact that a disk-budget rejection is not an isolation failure.

vet.idna_recheck names the static analyzer that enforces a post-encode-recheck contract on CALLER code: after any call to idna.ToASCII (package-level or via a *idna.Profile), the analyzer requires a net.ParseIP or netip.ParseAddr recheck within the next ten statements of the same block, or an explicit //nolint:idnarecheck suppression, and it emits VET_IDNA_RECHECK_MISSING through trace.EmitGlobal when neither is present. It is distinct from policy.idna, which is the runtime layer that performs IDNA normalization and already runs its OWN internal IP-literal rechecks inside NormalizeHostvet.idna_recheck exists precisely because code OUTSIDE NormalizeHost can call idna.ToASCII directly and skip that protection, and a build-time catch here is what proves such a caller was checked at all. A vet.idna_recheck catch is a source-code finding at analysis time, never a runtime block of a live request.

vet.ide_settings_recheck names the idesettings static analyzer (ide_settings_recheck.go), which models CVE-2025-68433 (the Zed editor trust-boundary issue): it flags a value loaded from a per-project IDE settings file (.zed/settings.json, .vscode/settings.json, .cursor/settings.json) that reaches a privileged sink (os/exec.Command, syscall.Exec, os.WriteFile, net/http.Get, net.Dial, and siblings) without an intervening Validate/Recheck/Verify/Allowlist/Sanitize/AssertSafe-named call. The analyzer is real and runs — it reports every finding via pass.Reportf() — but unlike its sibling vet.idna_recheck, it carries NO trace.EmitGlobal call anywhere in its implementation: it is structurally disconnected from the runtime catch-record vocabulary this registry governs. VET_IDE_SETTINGS_DRIFT is therefore a registered value with a real, running producer whose findings never reach a signed catch record today — the analyzer and the registry both exist, but nothing wires one to the other yet.

policy.egress_sinkhole names the userspace capture-and-drop interception point: in sinkhole mode, the upstream connection for a guest's dial is a local capture pipe rather than the real destination, and this layer records the signed EGRESS_SINKHOLE_CAPTURE catch — carrying the intended pre-DNAT destination and the scrubbed payload — immediately before the captured stream is dropped, across all three legs (raw TCP, HTTP cleartext, HTTPS). It is distinct from netconfig.nftables_moat, which is a KERNEL-level unconditional forward-chain drop that requires no user-space component to have run at all; policy.egress_sinkhole is precisely the opposite shape — a user-space capture that requires the connection to have already cleared every upstream policy gate and reached the proxy. It is also distinct from policy.network_hook and policy.authorize_context, both of which can block a request before it ever reaches the sinkhole; a policy.egress_sinkhole catch means those upstream gates already admitted the traffic and the sinkhole is the last line recording what would otherwise have reached the real destination.

netconfig.nftables_moat names the kernel packet-filter ruleset that the substrate stages around a sandbox's tap: the forward chain a guest's traffic must traverse, whose terminal verdict is deny. A catch at this layer is a verdict the KERNEL reached, before and independently of any user-space component. No existing layer can carry it, because every other layer names a user-space component that need not have run at all for the packet to be dropped: policy.authorize_context asserts that a host-admission decision was made, but a dropped packet may carry no host semantics to decide over (a conntrack-invalid retransmit, a neighbour-discovery frame, a raw ethertype); netconfig.dns_pin names the rebind-pin table specifically; policy.egress_sinkhole asserts that a stream was terminated and captured. Reusing any of them would place a mechanism into signed evidence that did not execute, which is precisely what a closed vocabulary exists to prevent.

The last two (vmi.attribution, quarantine.coverage) name the block-without-kill evidence-record classes rather than a layer that fired a catch: an owning-task attribution record and a coverage-closure snapshot. Both are catch-record-v1 records that ride the drained evidence stream, so they belong in the closed layer vocabulary a verifier validates. See catch-record for their record shapes.

A verifier MUST reject any layer value not in this list. A renamed layer is a breaking change (new major version).

violation_type

The symbolic reason a catch fired. A given violation_type MAY legitimately appear under more than one layer; the registry does not constrain which violation types co-occur with which layers. The v1 registered values are:

PATH_ESCAPE_DENIED
VFS_HOOK_BLOCKED
IDNA_IP_LITERAL_BLOCKED
IDNA_INTEGRITY_FAILED
IDNA_MALFORMED_LABEL
IDNA_TOO_LONG
IDNA_EMPTY_WILDCARD
ENCODED_PAYLOAD_SCRUBBED
HOST_NOT_ALLOWED
SSRF_ATTEMPT
NETWORK_HOOK_BLOCKED
PIN_MISS
PIN_IP_MISMATCH
PIN_EXPIRED
QUAD9_PIN_MISMATCH
QUAD9_PIN_EMPTY_CHAIN
QUAD9_PIN_ALL_MALFORMED
AD_QUORUM_MISMATCH
AD_QUORUM_TIMEOUT
AD_QUORUM_ALL_PEERS_ERRORED
ECH_REJECTION_UNEXPECTED
OVERLAY_LAYER_LIMIT
OVERLAY_DISK_LIMIT
VET_IDNA_RECHECK_MISSING
VET_IDE_SETTINGS_DRIFT
EGRESS_SINKHOLE_CAPTURE
MOAT_PACKET_DROPPED
EGRESS_OWNING_TASK_RESOLVED
QUARANTINE_COVERAGE

PATH_ESCAPE_DENIED names a guest path resolution that would climb above its mount root (a ..-relative traversal caught by resolveName's os.Root-style confinement in pkg/vfs/realfs.go, refused with EPERM rather than silently rewritten). The refusal itself is real and enforced today — TestEscapeRefusalReportsEPERM pins it — but it has NO production emit site: resolveName and its confineError wrapper return the refusal to the caller without ever calling trace.Emit or trace.EmitGlobal, so a path-escape refusal produces no signed catch record in the current codebase. This is distinct from its sibling VFS_HOOK_BLOCKED, which IS wired end-to-end (pkg/vfs/hooks.go's emitHookBlock) for every other guest filesystem refusal — stat, readdir, open, create, mkdir, chmod, remove, remove_all. PATH_ESCAPE_DENIED is registered because a path-escape refusal is a distinct violation class from a hook-engine block, but as of this version it names a reserved value with no observed emission.

VFS_HOOK_BLOCKED reports that the guest filesystem hook engine resolved a request — stat, readdir, open, create, mkdir, chmod, remove, or remove_all — to HookActionBlock, either through a caller-supplied ActionFunc or a static block rule, and emitHookBlock (pkg/vfs/hooks.go) recorded it before the operation reached the real filesystem. It is the general-purpose catch for every VFS-hook refusal, in contrast to its narrower sibling PATH_ESCAPE_DENIED, which names one specific refusal reason (a .. traversal) and — unlike VFS_HOOK_BLOCKED — currently emits no record at all. It is also distinct from NETWORK_HOOK_BLOCKED, the network-layer analog fired by the L7 rule engine under policy.network_hook; the two share a naming pattern but block entirely different primitives (a filesystem syscall versus a network request/response).

IDNA_IP_LITERAL_BLOCKED reports that NormalizeHost rejected a value because it is (or, after UTS #46 NFKC-style digit folding, becomes) an IP-literal rather than a DNS name — fired at three points in the pipeline: the pre-encode literal check, the wildcard-body re-check, and the post-IDNA-mapping re-check that exists specifically because non-ASCII digit sequences (superscripts, fullwidth digits) can fold into an ASCII IPv4 literal after ToASCII (the class FuzzNormalizeHost discovered, regression seed 5602a60fa23a9bf8). It is distinct from IDNA_MALFORMED_LABEL (a structurally invalid DNS label under RFC 1035 section 2.3.4 — empty labels, over-length labels, all-dots input) and IDNA_TOO_LONG (a well-formed name that exceeds the 253-octet FQDN cap): an IP literal is a categorically different kind of value than a malformed or oversized DNS name, and the policy engine's IP-CIDR surface and DNS-name surface must never be conflated, which is exactly what this violation type exists to keep separate.

IDNA_INTEGRITY_FAILED reports one of three distinct integrity failures NormalizeHost can detect over the same input: a pre-encode xn---prefixed label whose decoded form is pure ASCII (the CVE-2024-12224 pattern Go's idna library silently accepts but Python's does not, so Probity pre-checks the INPUT itself before encoding); a bare ToASCII encoding error from the UTS #46 + IDNA 2008 profile; or a failed encode-decode-re-encode round trip, which catches any non-idempotent output a future Unicode table update might introduce even after CheckHyphens already rejects the known xn--example- pattern. All three share this one violation type because each is an INTEGRITY failure — the library or the input behaved inconsistently — as opposed to IDNA_MALFORMED_LABEL, which reports a structurally well-formed-but-invalid DNS label (RFC 1035 section 2.3.4 geometry: empty labels, 64-octet labels, all-dots input) where the encoding itself succeeded honestly.

IDNA_MALFORMED_LABEL reports that validateACELabels rejected the post-ToASCII ASCII-compatible-encoding form for violating RFC 1035 section 2.3.4 DNS label geometry — empty labels, an all-dots input, or a label exceeding the 63-octet limit — rules that golang.org/x/net/idna does not itself enforce because it short-circuits pure-ASCII labels through LDH-only validation. It is distinct from IDNA_TOO_LONG, which caps the fully-assembled FQDN (including any wildcard prefix) at 253 octets and fires only after label geometry has already passed; a name can fail IDNA_MALFORMED_LABEL on a single 64-octet label while being well under the 253-octet total, and conversely pass every per-label geometry check yet still be rejected as IDNA_TOO_LONG once assembled. The two violation types cover RFC 1035's per-label and whole-name length rules respectively, and neither subsumes the other.

IDNA_TOO_LONG reports that the fully-assembled result of NormalizeHost — the wildcard prefix (*., if present) plus the canonical ASCII-compatible-encoding form — exceeds the RFC 1035 253-octet FQDN limit, checked after label geometry (IDNA_MALFORMED_LABEL) has already passed and before the final round-trip integrity check (IDNA_INTEGRITY_FAILED) runs. It is a whole-name budget, not a per-label one: a name assembled entirely from valid, correctly-sized labels can still fail IDNA_TOO_LONG once the label count pushes the total over budget, which is exactly why it is a separate violation type from IDNA_MALFORMED_LABEL rather than a variant of it — the two check different RFC 1035 constraints (label shape versus total length) at different stages of the same pipeline.

IDNA_EMPTY_WILDCARD reports that a value entered NormalizeHost with wildcard matching enabled, began with the *. prefix, and had nothing left after the prefix was stripped — a pattern like *. with no host body to match against. It fires early in the pipeline, before any IDNA encoding is attempted, which is what distinguishes it from every other IDNA_* violation type: IDNA_IP_LITERAL_BLOCKED, IDNA_MALFORMED_LABEL, IDNA_TOO_LONG, and IDNA_INTEGRITY_FAILED all evaluate the CONTENT of a hostname, whereas IDNA_EMPTY_WILDCARD fires on the absence of content — a degenerate pattern that would otherwise either error obscurely deeper in the pipeline or, worse, be silently treated as matching every possible host.

ENCODED_PAYLOAD_SCRUBBED reports that SecretScrubber.ReplaceAll or ReplaceAllString found and replaced secret bytes inside an HTTP response body or SSE data line — the record fires only when the scrubbed output actually differs from the input, and it deliberately never carries the raw secret material or even its byte length in any signed or quantified field, only the fact that a replacement happened. It is distinct from EGRESS_SINKHOLE_CAPTURE, which captures and drops an entire stream rather than rewriting bytes within one that continues onward: an ENCODED_PAYLOAD_SCRUBBED record describes traffic that WAS still forwarded, with the secret redacted in flight, whereas an EGRESS_SINKHOLE_CAPTURE record describes traffic that never reached its intended destination at all. The two violation types therefore report opposite dispositions of an in-flight secret — sanitized-and-forwarded versus captured-and-dropped.

HOST_NOT_ALLOWED is the generic host-admission denial: a destination that failed the allowlist check for any reason other than resolving to a private, link-local, or IMDS address. It fires from every policy.authorize_context call site that refuses a host — an empty resolved-address set, an AllowedIPs-filtered survivor set of zero, or a bare IsHostAllowed observation reported through EmitHostDenyObservation — and it is also the value hostDenyViolation falls back to whenever BlockPrivateIPs is off or the rejected address is not itself private. It is distinct from SSRF_ATTEMPT, its own specialization: when BlockPrivateIPs is enabled and every surviving resolved address was dropped specifically because it is private/link-local/IMDS, hostDenyViolation reports SSRF_ATTEMPT instead, because a hostname resolving into the private network inside a sealed gate is a server-side-request-forgery signal the generic bucket would otherwise hide from downstream triage.

SSRF_ATTEMPT is the specialization of HOST_NOT_ALLOWED for an egress whose resolved destination is a private, link-local, or loopback address (RFC 1918, 169.254.0.0/16 IMDS, and the rest of the privateRanges set) denied under BlockPrivateIPs. hostDenyViolation and FilterPinned choose this value instead of the generic HOST_NOT_ALLOWED precisely because, in a sealed gate, the system under test has no legitimate private-network destination — so a hostname or address that lands in the private range is a server-side-request-forgery or metadata-exfiltration probe, not merely an un-allowlisted public host. Emitting it as a distinct value lets a downstream triage pipeline auto-classify the attempt instead of collapsing every allowlist denial into one undifferentiated HOST_NOT_ALLOWED bucket, which is the whole reason the two values are kept separate rather than merged.

NETWORK_HOOK_BLOCKED reports that the L7 network-hook rule engine matched an in-flight request or response against a rule's host glob, HTTP method set, or path pattern and blocked it, recorded by emitNetworkBlock under the policy.network_hook layer with SinkBlocked fixed at Dial. It is distinct from HOST_NOT_ALLOWED, which is decided earlier and at a coarser grain — whether the destination may be dialed AT ALL, before any request has even been formed — whereas NETWORK_HOOK_BLOCKED can fire against a destination that already passed host-admission, on the finer-grained shape of the specific request (its method or path). It is also distinct from its filesystem-layer namesake VFS_HOOK_BLOCKED: the two share a naming convention (*_HOOK_BLOCKED) but block entirely different primitive classes, and neither vocabulary entry implies anything about the other's layer.

PIN_MISS reports that FilterPinned found a populated PinTable but no entry at all for the canonicalized host being authorized — every resolved address survived to this point, but the pin table has nothing recorded for this hostname to check them against. It is distinct from PIN_IP_MISMATCH, which reports the opposite shape: an entry EXISTS for the host, but the resolved (or, at VerifyDialedAddr, the actually-dialed) address is not among the pinned addresses for it. PIN_MISS is therefore a missing-registration failure and PIN_IP_MISMATCH is a wrong-address failure over an existing registration — a verifier reading either can tell immediately whether the pin table knew the host at all.

PIN_IP_MISMATCH reports that a PinTable entry exists for the host but the address under test — either the intersection of resolved addresses at FilterPinned or the actually-dialed address re-checked at VerifyDialedAddr's ControlContext callback — is not among the pinned set for it. It is distinct from PIN_MISS (no entry exists for the host at all, so there is nothing to mismatch against) and from the registered-but-unemitted PIN_EXPIRED, which PinTable.Verify's documented contract reserves for an entry that exists but has aged out (ErrPinExpired) rather than one whose addresses simply disagree with the ones under test — today every ErrPinExpired return reaches the caller as a Go error without a corresponding catch record, so PIN_IP_MISMATCH remains the only address-disagreement value actually observed in signed evidence.

PIN_EXPIRED names the pin-table outcome PinTable.Verify's documented contract calls entry expired: a populated pin exists for the host, but its recorded validity window has aged out (ErrPinExpired, per VerifyDialedAddr's doc comment at pkg/policy/authorize_context.go). The error path exists in the type's documented contract, but NO call site in the current codebase maps an ErrPinExpired return to emitAuthorizeReject(trace.ViolationPinExpired, ...) or any other trace.EmitGlobal call — unlike its siblings PIN_MISS and PIN_IP_MISMATCH, which both have live emit sites in FilterPinned and VerifyDialedAddr, PIN_EXPIRED has NO production emit site today. It is registered because pin expiry is a distinct outcome from a missing entry or a mismatched address, but as of this version it names a reserved value with no observed emission.

QUAD9_PIN_MISMATCH reports that enforce() computed the SHA-256 of at least one usable certificate's RawSubjectPublicKeyInfo in the presented chain but none matched the embedded Quad9 SPKI pin set, checked only after the graceful-decay window (GracefulDecayDays, the default beyond which a non-HardFail policy accepts CA-trust alone) has NOT yet elapsed. It is distinct from QUAD9_PIN_ALL_MALFORMED, which fires earlier in enforce() when the chain contains zero usable certificates at all (nothing to hash), and from QUAD9_PIN_EMPTY_CHAIN, which fires even earlier, at the wrapper level, when the wire itself carried no certificates (rawCerts or PeerCertificates empty) before enforce() is ever called. The three violation types mark three different points at which a Quad9 DoT handshake can fail to produce a trusted, pin-matching certificate.

QUAD9_PIN_EMPTY_CHAIN reports that the TLS stack handed VerifyPeerCertificate or VerifyConnection a wire-level empty certificate chain (rawCerts or cs.PeerCertificates has zero entries) before enforce()'s pin logic ever runs. This is documented as wire-side semantics — a stdlib glitch or genuine peer misbehavior at the transport level — which is what distinguishes it from QUAD9_PIN_ALL_MALFORMED: that value fires INSIDE enforce() when the wire delivered cert bytes but every one of them failed to parse into a usable certificate (parse-side semantics: an attacker-shaped chain designed to look non-empty while carrying nothing enforce() can hash). The distinction matters operationally — an empty chain and an all-malformed chain point an incident responder toward different root causes even though both result in the same hard failure.

QUAD9_PIN_ALL_MALFORMED reports that enforce() received a chain of one or more certificates but none had a usable RawSubjectPublicKeyInfo to hash — a chain shape that is malformed regardless of the decay window's state, checked and rejected before the decay short-circuit runs so a wire that carried bytes but produced zero usable certs cannot silently fall through to a decay-window CA-trust accept at day 90+. It is distinct from QUAD9_PIN_EMPTY_CHAIN, which is the wire-level empty-chain case caught by the wrapper closures before enforce() is even invoked (nothing arrived at all, versus something arrived that could not be used), and from QUAD9_PIN_MISMATCH, which fires only after usable certificates ARE found but none of them match the pin set. QUAD9_PIN_ALL_MALFORMED sits between the two: certificates arrived, but none of them were usable enough to even attempt a pin comparison.

AD_QUORUM_MISMATCH reports that every peer resolver responded (or errored) within the quorum window, at least one peer response was NOT an error, and yet no peer's answer corroborated the primary's AD=1 digest — the peers were reachable and answered, but they disagreed with (or among) themselves rather than confirming the primary. It is distinct from AD_QUORUM_TIMEOUT, which fires when the bounded window expires before ANY peer responds at all — an infrastructure-silence signal rather than a disagreement signal — and from AD_QUORUM_ALL_PEERS_ERRORED, which fires when every single peer returned a transport error rather than an answer. The three violation types deliberately separate 'peers disagreed' (suspicious — possible primary compromise) from 'peers were silent' and 'peers were unreachable' (both infrastructure problems), because an operator reading the metric stream needs to tell those apart.

AD_QUORUM_TIMEOUT reports that the bounded cross-quorum window (DefaultADQuorumWindow, 200ms in production) elapsed before any peer resolver produced a result at all, so the primary's AD=1 answer has zero corroboration to evaluate. It is distinct from AD_QUORUM_ALL_PEERS_ERRORED, which requires every peer to have actively returned before the window closed, each with a transport error — timeout means the peers never got the chance to answer either way, whereas all-peers-errored means they all did answer, and every answer was a failure. It is also distinct from AD_QUORUM_MISMATCH, where peers DID respond in time but simply failed to corroborate; AD_QUORUM_TIMEOUT specifically names the case where the window itself, not the peers' answers, is what closed the query.

AD_QUORUM_ALL_PEERS_ERRORED reports that every peer resolver in the quorum set returned a transport error within the window rather than any of them answering — the DoH transport to every configured peer failed, not merely disagreeing with the primary. It is distinct from AD_QUORUM_TIMEOUT, where the window expired without ANY peer result (error or success) arriving in time, and from AD_QUORUM_MISMATCH, where at least one peer DID return a non-error result but none corroborated the primary. The distinction between all-errored and timeout is deliberate: an operator monitoring the metric stream needs to tell 'the peer DoH transport is broken' apart from 'nothing came back in the window at all', and collapsing the two into one value would erase that diagnostic signal.

ECH_REJECTION_UNEXPECTED reports that the stdlib TLS stack invoked VerifyECHRejection on the Quad9 DoT connection at all — Probity does not adopt Encrypted Client Hello on this path (the DNS-bootstrap chicken-and-egg problem plus a moot privacy gain for a service-controlled outbound client), so under the default ECHReject policy this closure treats its own invocation as a hard failure regardless of what the server's rejection actually contained. It is distinct from every other Quad9 violation type (QUAD9_PIN_MISMATCH, QUAD9_PIN_EMPTY_CHAIN, QUAD9_PIN_ALL_MALFORMED) because those all fire from cert-chain inspection inside VerifyPeerCertificate or VerifyConnection, while an ECH rejection SUPPRESSES both of those callbacks entirely — ECH_REJECTION_UNEXPECTED is the only Quad9 catch that can fire without either of the other two ever having had a chance to run.

OVERLAY_LAYER_LIMIT reports that validateOverlayDiskLayout rejected a sandbox construction because the requested lower-layer count exceeds maxOverlayLowerLayers (20), checked first and independently of the total device count. It is distinct from OVERLAY_DISK_LIMIT, which is a separate budget over the TOTAL block-device count (root + lowers + upper + extra, capped at Firecracker's 24-device ceiling) checked only after the layer-count check has already passed — a request can fail OVERLAY_LAYER_LIMIT alone (too many lowers, even before adding root/upper/extra), or pass it and still fail OVERLAY_DISK_LIMIT once the fixed overhead pushes the total over budget. Keeping the two separate lets a reader tell which specific budget (layer count versus total device count) the request violated.

OVERLAY_DISK_LIMIT reports that validateOverlayDiskLayout rejected a sandbox construction because the total block-device count — root plus lower layers plus the upper writable layer plus any extra user-provided disks — exceeds Firecracker's 24-device ceiling, checked after the OVERLAY_LAYER_LIMIT layer-count budget has already passed. It is the total-device budget as opposed to OVERLAY_LAYER_LIMIT's lower-layer-only budget: a request within the 20-layer limit can still exceed the 24-device ceiling once the fixed root/upper overhead and any extra disks are added in, which is exactly the case this violation type exists to catch separately.

VET_IDNA_RECHECK_MISSING reports that the idnarecheck static analyzer found a call to idna.ToASCII whose result is not re-checked with net.ParseIP or netip.ParseAddr within the next ten statements of the enclosing block, and is not suppressed with a //nolint:idnarecheck comment — the UTS #46 NFKC digit-folding hazard that lets a visually-distinct Unicode digit sequence encode into an ASCII string that parses as an IP literal. It is the only violation type this vocabulary registers that is emitted from a go/analysis pass rather than from a runtime code path, and it is distinct from the runtime IDNA_IP_LITERAL_BLOCKED: that value reports a live rejection INSIDE NormalizeHost's own internal recheck, whereas VET_IDNA_RECHECK_MISSING reports that some OTHER caller's code, outside NormalizeHost, was never proven to have that same protection at all.

VET_IDE_SETTINGS_DRIFT names the diagnostic the idesettings analyzer reports (ide_settings_recheck.go) when a value loaded from a per-project IDE settings file reaches a privileged sink without an intervening revalidation call, modeling CVE-2025-68433. The analyzer is real, runs today, and reports every finding through pass.Reportf() with a detailed diagnostic message — but it never constructs a trace.CatchRecord or calls trace.EmitGlobal, unlike its sibling VET_IDNA_RECHECK_MISSING, whose analyzer (idnarecheck) DOES emit through trace.EmitGlobal alongside its pass.Reportf call. VET_IDE_SETTINGS_DRIFT is therefore registered vocabulary with a genuine, currently-running producer, but that producer is structurally disconnected from the signed catch-record path this registry governs — a finding reaches the go vet-style output stream and nowhere else today.

EGRESS_SINKHOLE_CAPTURE reports that, in sinkhole deployment mode, a guest's dial to a policy-gated destination was routed to a local capture-and-drop pipe instead of the real upstream, and the intended pre-DNAT destination plus the scrubbed payload were recorded before the stream was dropped — the one violation type shared across all three sinkhole legs (raw TCP, HTTP cleartext, HTTPS), distinguished from each other only by the accompanying sink value (tcp.Passthrough, Dial, tls.Handshake). It is distinct from MOAT_PACKET_DROPPED, the kernel-level forward-chain drop that requires no captured payload and no user-space component to have run; and from ENCODED_PAYLOAD_SCRUBBED, which redacts secret bytes inside traffic that is still forwarded rather than capturing and dropping the whole stream. EGRESS_SINKHOLE_CAPTURE is also one of the four violation types (alongside HOST_NOT_ALLOWED, SSRF_ATTEMPT, and NETWORK_HOOK_BLOCKED) the quarantine block-without-kill arm binds on, because a captured egress carries the destination sink tuple the arm needs to attribute a blocked flow to its owning guest task.

MOAT_PACKET_DROPPED reports that the kernel discarded a packet at the forward hook. It is distinct from the three violation types a reader might otherwise reach for, each of which asserts something stronger than a drop: HOST_NOT_ALLOWED asserts an allowlist verdict reached over a resolved name or address; NETWORK_HOOK_BLOCKED asserts a decision by the L7 rule engine; EGRESS_SINKHOLE_CAPTURE asserts a capture that, for a dropped packet, did not happen.

One violation type covers every kernel drop, and the producer's internal drop taxonomy is deliberately NOT mirrored into this registry. A published cross-language vocabulary must not become the bottleneck on ordinary substrate hardening: a producer that adds, splits or retires a drop rule would otherwise need a registry revision, and its verifiers would need to adopt it, before the rule could ship. Producers MAY carry a finer internal classification in their own unsigned telemetry, and MAY ride the split on the OPTIONAL sink when one applies, exactly as EGRESS_SINKHOLE_CAPTURE distinguishes its L4 and L7 legs through sink rather than through two violation types.

EGRESS_OWNING_TASK_RESOLVED is CatchRecord.ViolationType for a vmi.attribution record: the SECOND signed catch record in the block-without-kill evidence pair, joined to the triggering egress catch (one of HOST_NOT_ALLOWED, SSRF_ATTEMPT, NETWORK_HOOK_BLOCKED, or EGRESS_SINKHOLE_CAPTURE) by catch_seq, attributing the blocked-but-non-terminal egress to the guest task and ModelBOM that owned it via a paused-guest socket walk. It is distinct from every violation type above it in this list in that it does not itself describe a policy refusal — the refusal already happened and was already recorded under its own layer — this value describes a SEPARATE, subsequent act of attribution over that refusal. It is also distinct from QUARANTINE_COVERAGE, its sibling block-without-kill record type: EGRESS_OWNING_TASK_RESOLVED is a per-catch attribution record, one per blocked egress, while QUARANTINE_COVERAGE is a run-level accounting snapshot covering every catch the arm has seen.

QUARANTINE_COVERAGE is CatchRecord.ViolationType for a quarantine.coverage record: a run-level snapshot of the block-without-kill arm's own bookkeeping — how many catches it saw versus attributed versus suppressed, under a strict equality closure (catches_seen == attributed + dedup_suppressed + budget_suppressed + breaker_suppressed + pending_overflowed + pending_unserviced + not_bindable + internal_faulted + in_flight + queued). It is explicitly a vocabulary marker, not an offense: the record accounts the arm's own work rather than reporting a caught egress, which is what separates it from every other violation type in this registry — HOST_NOT_ALLOWED through EGRESS_OWNING_TASK_RESOLVED all describe something the substrate caught the guest doing, while QUARANTINE_COVERAGE describes whether the arm's own accounting is complete and trustworthy up to its last signed flush.

A verifier MUST reject any violation_type value not in this list.

sink

The syscall, dial, or exec primitive that was prevented. This vocabulary is OPTIONAL in a catch record (a catch record MAY omit sink), but when present the value MUST be a registered one. The v1 registered values are:

openat
Dial
exec.Command
vfs.Hook
tls.Handshake
dns.Exchange
tcp.Passthrough
netfilter.Forward

openat names the sink emitHookBlock (pkg/vfs/hooks.go) always records for a substrate.vfs_hook block, regardless of which specific hook operation fired — stat, readdir, open, create, mkdir, chmod, remove, and remove_all all record openat, not a syscall-accurate name per operation. It stands for the guest filesystem hook's file-primitive family as a whole rather than the literal openat(2) syscall for every one of those ops. It is distinct from vfs.Hook, a differently-worded sink that — despite the name — is NOT emitted by the VFS hook layer at all; its sole production emission is under policy.scrub_encodings. A reader must not assume vfs.Hook and openat are interchangeable names for the same primitive family; they are emitted by entirely different layers today.

Dial names the network-dial primitive prevented from reaching its destination, and it is the fixed sink for every policy.authorize_context denial (emitAuthorizeReject hardcodes it, since every call site in that file is a dialer-family phase method), for every NETWORK_HOOK_BLOCKED record, and for the HTTP-cleartext leg of an EGRESS_SINKHOLE_CAPTURE (the sinkhole intercepts the Write that would otherwise reach the real destination). It is distinct from tcp.Passthrough, the sinkhole's raw-TCP leg sink, and from tls.Handshake, the sinkhole's HTTPS leg sink — the three sinks let a forensic reader tell which of the three sinkhole legs (cleartext HTTP, raw TCP, or TLS-terminated HTTPS) captured a given egress, even though all three can carry the same EGRESS_SINKHOLE_CAPTURE violation type.

exec.Command names the process-launch primitive an OVERLAY_LAYER_LIMIT or OVERLAY_DISK_LIMIT rejection prevents: validateOverlayDiskLayout runs before the sandbox's Firecracker microVM process is ever launched, so a disk-layout rejection means the exec.Command invocation that would start the VMM binary never happens at all. It is distinct from Dial, vfs.Hook, and tls.Handshake, which all name primitives prevented at RUNTIME against an already-running guest; exec.Command is the only sink in this registry that names a primitive prevented before a guest sandbox exists to run anything, at the moment of sandbox construction itself.

vfs.Hook names the hook-mediated content-interception point at which SecretScrubber.ReplaceAll / ReplaceAllString rewrites bytes; its sole production emission today is under ENCODED_PAYLOAD_SCRUBBED at the policy.scrub_encodings layer, invoked from the HTTP response body and SSE data-line scrub calls in pkg/policy/network_hooks.go. This is the one place in the registry where a sink's name and its actual emitting layer diverge from what the name suggests: substrate.vfs_hook — the layer whose name most resembles vfs.Hook — records openat as its sink, never vfs.Hook. A reader must resolve vfs.Hook by its actual emission site (a content-scrub interception point) rather than by name association with the VFS hook layer, which is a distinct, unrelated mechanism.

tls.Handshake names the TLS handshake primitive, and it is emitted from two unrelated call sites that happen to share the same primitive name: the Quad9 SPKI pin verifier (emitPinReject in pkg/netconfig/dns_pin_quad9.go), where it marks the DoT upstream handshake a pin rejection prevented from completing; and the HTTPS leg of an egress-sinkhole capture (emitSinkholeCapture in pkg/netconfig/http.go), where it marks the upstream TLS leg the sinkhole stood in for. The two emission sites carry different layers and different violation types (netconfig.dns_pin / QUAD9_PIN_* versus policy.egress_sinkhole / EGRESS_SINKHOLE_CAPTURE), so a verifier must read layer and violation_type together with sink to tell a Quad9 pin failure from a captured HTTPS egress — tls.Handshake alone under-determines which of the two occurred.

dns.Exchange names the DNS query/response exchange primitive, and it is emitted from three distinct call sites: every IDNA_* rejection (emitIDNAReject fixes this sink for the whole policy.idna layer, since a rejected hostname was never going to reach a DNS exchange with that value), the DNS-proxy allowlist chokepoint's HOST_NOT_ALLOWED observation (policy.EmitHostDenyObservation in pkg/netconfig/dns.go, for a name or CNAME target the DNS-side allow-predicate refused), and every AD_QUORUM_* rejection (emitQuorumReject in pkg/netconfig/dns_ad_quorum.go). All three call sites genuinely prevented a DNS exchange from completing on the attacker's terms, unlike vfs.Hook, whose name-to-emission-site relationship is a documented exception; dns.Exchange is a straightforward, literal match between its name and what it records across every site that emits it.

tcp.Passthrough marks an egress-sinkhole capture on the raw, non-HTTP TCP passthrough path: a guest dialled a policy-gated TCP port that is not HTTP/HTTPS, so there is no request line to parse — the sinkhole captured the raw stream and dropped it. It is distinct from Dial (the HTTP cleartext sinkhole leg) so a forensic reader can tell an L4 raw exfil (e.g. a database DROP TABLE to port 5432) from an L7 HTTP capture.

netfilter.Forward names the packet-forwarding primitive itself: the prevented operation is forwarding the packet onward, and every rule that can reach a drop verdict sits on the forward chain. It is registered so that a kernel drop is not the one egress record with no sink at all — sink is a field forensic consumers pivot on, and a whole record class that never carries one reads as missing data rather than as an answered question.

A verifier MUST reject any present sink value not in this list.

task_resolution

How a vmi.attribution catch record's egress-owning task was resolved from the paused-guest socket walk. Present in the record's task_resolution field and summed into the quarantine.coverage record's task_resolution histogram. The v1 registered values are:

4-tuple
dst-fallback
dst-only

4-tuple is the strongest binding (the owning task was matched on the full source/destination address 4-tuple); dst-fallback matched on the destination alone after the source could not be recovered; dst-only is a destination-only attribution with no source corroboration. A verifier MUST reject any task_resolution value not in this list.

verity_device_pairing

How a quarantine.coverage record's dm-verity lower device was paired to its model image during the host-VMI walk. Present in the record's verity_device_pairing field. The v1 registered values are:

verity-exact-root-digest
verity-heuristic-size

verity-exact-root-digest is the strong pairing (the enforced dm-verity root hash recovered from guest memory matched the model image's veritysetup digest exactly); verity-heuristic-size is the weaker size-based heuristic used when the exact root digest could not be recovered. A verifier MUST reject any present verity_device_pairing value not in this list.

auth_downgrade_cause

The composition-safety violation reason an offline mesh evaluator records in an a2a-mesh-scorecard's verdict.causes. These are the trace-to-auth-downgrade invariant cause set: the specific ways authority can fail to attenuate as it flows hop-to-hop through an A2A mesh. The v1 registered values are:

IDENTITY_MISMATCH
TEMPORAL
DOWNGRADE
SCOPE_BROADENING
TOKENLESS_BREAK

A verifier MUST reject any auth_downgrade_cause value not in this list. The per-cause meaning is documented in a2a-mesh-scorecard.md. A renamed or removed cause is a breaking change (new major version); an added cause is a registry revision.

How consumers use the registry

  • A catch-record MUST carry layer and violation_type values drawn from this registry, and MAY carry a sink value drawn from this registry.
  • A producer MUST map any internal or per-runtime naming onto these exact registered strings before emitting a record. Equality comparisons in a verdict (for example, matching an actual layer to an expected layer) are decided over these normalized strings.
  • A verifier validates every enum value in every catch record it processes against the registry version it implements, and rejects the record (and any verdict that depends on it) on any unregistered value.
  • An a2a-mesh-scorecard MUST carry verdict.causes values drawn from the auth_downgrade_cause list, and a verifier MUST reject a scorecard carrying an unregistered cause.

Related standards

  • SARIF - SARIF defines its own taxonomies and rule identifiers. This registry is independent of SARIF's taxonomy; per-finding check_id values in layer-carrying verdicts are the SARIF crosswalk point, not these layer / violation / sink vocabularies. (Layer findings are out of the adversarial-execution-evidence core; its layer / violationType values ARE gated by this registry.)
  • IANA registries - the governance model (a maintained, versioned, publicly-reviewed list of allowed values) follows the spirit of an IANA-style registry, scaled to this standard.

Versioning

This is the v1 registry. Any addition is a registry revision published as a new list; any rename or removal is a breaking change requiring a new major version. A v1 verifier evaluates values strictly against the v1 lists above and fails closed on anything else.

Get access

Run your agents through Probity.

A few details about your setup and we'll get you gating agents fast.