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, orsinkvalue 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
CONTRIBUTINGdocument 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-namedvet.idna_recheck, which is a build-time static-analysis finding about caller code that skips a DIFFERENT IDNA hazard (missing a post-ToASCIInet.ParseIPrecheck) and never fires at runtime; and fromnetconfig.dns_pin, which enforces a DNS-response pin match strictly after a hostname has already survivedpolicy.idnanormalization. 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 NormalizeHost — vet.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
layerandviolation_typevalues drawn from this registry, and MAY carry asinkvalue 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.causesvalues drawn from theauth_downgrade_causelist, 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_idvalues 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; itslayer/violationTypevalues 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.