# Finding-code registry (stable public API)

> **Status:** v1 (Phase 0 seed). Owned by this file. **Every code below
> is a public contract** — once published in `schema_version: 1`, it can
> only be removed via a coordinated breaking change with a
> `schema_version` bump. Adding new codes is non-breaking.

Finding codes are the stable surface that downstream agents pivot on.
A LLM-driven workflow seeing `KEY_TOO_SHORT` should be able to reach
for a documented remediation; if the code silently renames to
`DKIM_KEY_LENGTH_BELOW_THRESHOLD` next quarter, that workflow breaks.
This registry is what stops that.

## Rules

1. **SCREAMING_SNAKE_CASE.** Always.
2. **Globally unique.** No two tools emit the same code with different
   meanings. Cross-tool reuse with identical meaning IS allowed (e.g.
   `MULTIPLE_DMARC_RECORDS` is emitted by both the DMARC checker and
   the BIMI checker because BIMI's prerequisite includes a DMARC
   lookup).
3. **No tool prefix unless the meaning is tool-specific.** Generic
   structural codes (`MULTIPLE_<TYPE>_RECORDS`, `LOOKUP_FAILED`,
   `NXDOMAIN`) carry no prefix. Tool-specific codes (`BIMI_VMC_EXPIRED`,
   `SPF_RECORD_TOO_LONG`) carry the tool prefix when the same condition
   on a different RR type would have different remediation.
4. **No version suffix.** Bumping meaning is a `schema_version`
   change, not `CODE_V2`.
5. **Severity is set per-code, not per-emission.** A given code always
   has the same severity. Tools that need both warn and error variants
   register two codes (`NEAR_LOOKUP_LIMIT` warn, `LOOKUP_LIMIT_EXCEEDED`
   error).
6. **Adding a new code:** open a PR that updates this file AND the
   emitting checker AND `tests/shape/finding-code-registry.test.php`
   in the same commit. The shape test fails if a checker emits a code
   not listed here.

## Linting

`tests/shape/finding-code-registry.test.php` walks every checker's
output across the existing test fixtures and asserts:

- Every emitted `code` exists in this file.
- The emitted `severity` matches the registered severity.
- No two codes registered with different meanings collide.

CI fails on any violation. The mandate in `CLAUDE.md` makes this
non-negotiable.

## Code registry (v1)

### Why "no record" severities differ across tools

A missing DMARC record is graded `error` because DMARC is the policy contract
that binds the other signals together — with no DMARC record there is nothing
for receivers to enforce, so the whole posture collapses. A missing SPF, DKIM,
or BIMI record is graded `info` because each is one signal among several
*within* that contract: absence weakens the posture but does not void it. An
agent diffing two scans can rely on this ladder — the severity reflects
business impact, not implementation quirk.

### Generic / structural

| Code | Severity | Meaning |
|---|---|---|
| `NXDOMAIN` | error | Authoritative empty Answer for the queried name. |
| `LOOKUP_FAILED` | error | DoH consensus tier failed; no answer at all. |
| `RESOLVERS_DISAGREE` | warn | Consensus probe got divergent answers from the three resolvers. |
| `RESOLVERS_PARTIAL` | warn | Some resolvers timed out; partial consensus. |
| `RECORD_TOO_LONG` | warn | Single RDATA exceeds the typical TXT 255-byte chunk boundary in an awkward way. |
| `MULTIPLE_<TYPE>_RECORDS` | error | More than one record where the spec mandates ≤1. Concrete instances: `MULTIPLE_SPF_RECORDS`, `MULTIPLE_DMARC_RECORDS`, `MULTIPLE_BIMI_RECORDS`, `MULTIPLE_MTA_STS_RECORDS`. |

### SPF — `check_spf`

| Code | Severity | Meaning |
|---|---|---|
| `NO_SPF_RECORD` | info | No `v=spf1` TXT at the apex. Receivers cannot authenticate via SPF. |
| `NEAR_LOOKUP_LIMIT` | warn | RFC 7208 §4.6.4 — 9+ DNS lookups out of 10. |
| `LOOKUP_LIMIT_EXCEEDED` | error | RFC 7208 §4.6.4 — over 10 DNS lookups; `PermError`. |
| `VOID_LOOKUP_LIMIT_EXCEEDED` | error | RFC 7208 §4.6.4 — over 2 void lookups; `PermError`. |
| `PLUS_ALL_DANGEROUS` | error | `+all` accepts mail from anywhere — same risk as no SPF. |
| `NEUTRAL_ALL_WEAK` | warn | `?all` gives no opinion; receivers may treat as `+all`. |
| `NO_ALL_MECHANISM` | warn | Record has no `all` mechanism; receivers default to neutral. |
| `REDIRECT_AND_ALL_CONFLICT` | warn | `redirect=` is ignored when an explicit `all` mechanism is present. |
| `PTR_MECHANISM_DEPRECATED` | warn | RFC 7208 §5.5 — `ptr` SHOULD NOT be used. |
| `MULTIPLE_SPF_RECORDS` | error | More than one `v=spf1` TXT at the same name. |
| `SPF_RECORD_TOO_LONG` | warn | TXT record body > 450 bytes; may hit resolver buffer issues. |
| `SPF_ANALYSIS_FAILED` | error | Reserved for `error.code` (not a finding). Class layer threw. |

### DKIM — `check_dkim`

| Code | Severity | Meaning |
|---|---|---|
| `NO_DKIM_KEY` | info | No TXT at `<selector>._domainkey.<domain>`. Selector mismatch or no key published. |
| `KEY_TOO_SHORT` | error | RSA key < 1024 bits. Major auth providers reject. |
| `KEY_WEAK` | warn | RSA key < 2048 bits. Acceptable but not recommended. |
| `KEY_TOO_LONG` | error | `p=` field exceeds 8 KB — almost certainly malformed. |
| `KEY_REVOKED` | error | `p=` is empty. Key has been intentionally revoked. |
| `SHA1_ONLY` | warn | `h=sha1` is deprecated. Allow `h=sha256`. |
| `DKIM_TEST_MODE` | warn | `t=y` — receivers may treat signatures as test-only. |
| `DKIM_LOOKUP_FAILED` | error | Reserved for `error.code`. DoH tier failed for `<selector>._domainkey.<domain>`. |
| `DKIM_PARSE_FAILED` | error | Reserved for `error.code`. Record found but unparseable. |

### DMARC — `check_dmarc`

| Code | Severity | Meaning |
|---|---|---|
| `NO_DMARC_RECORD` | error | Neither `_dmarc.<domain>` nor `_dmarc.<org-domain>` exists. |
| `MULTIPLE_DMARC_RECORDS` | error | More than one `v=DMARC1` at the same name. |
| `POLICY_NONE` | warn | `p=none` — monitoring only, spoofed mail is not blocked. |
| `POLICY_NONE_NO_RUA` | error | `p=none` AND no `rua=` — published but does nothing. |
| `PCT_BELOW_100` | warn | `pct=` is below 100; only that percentage of failing mail is sampled. |
| `SP_NOT_ENFORCED` | warn | `sp=none` while `p=` is enforced — subdomains spoofable. |
| `EXTERNAL_RUA_UNAUTHORIZED` | warn | `rua=` destination is on a different org and missing `_report._dmarc.<rua-domain>` authorisation. |
| `DMARC_INHERITED_FROM_ORG` | info | Match came from `_dmarc.<org-domain>`, not from the queried name. |
| `INVALID_TAG` | warn | Record contains a syntactically invalid DMARC tag. |
| `RUA_MISSING_UNDER_ENFORCEMENT` | warn | `p=quarantine` or `p=reject` without any `rua=` destination. |

### BIMI — `check_bimi`

| Code | Severity | Meaning |
|---|---|---|
| `BIMI_NOT_CONFIGURED` | info | No `v=BIMI1` TXT at `default._bimi.<domain>`. No brand logo will display. |
| `MULTIPLE_BIMI_RECORDS` | error | More than one `v=BIMI1` TXT at the same name. |
| `BIMI_NO_LOGO_URL` | error | `l=` tag missing or empty. |
| `BIMI_LOGO_URL_INVALID` | error | `l=` is malformed or not absolute HTTPS. |
| `BIMI_LOGO_NOT_HTTPS` | error | `l=` is `http://` — receivers MUST reject. |
| `BIMI_LOGO_UNREACHABLE` | error | Fetch of `l=` failed (DNS, TCP, TLS, or HTTP ≥400). |
| `BIMI_LOGO_NOT_SVG` | error | `l=` response Content-Type isn't `image/svg+xml`. |
| `BIMI_LOGO_NOT_TINY_PS` | error | SVG missing `baseProfile="tiny-ps"` on root `<svg>`. |
| `BIMI_LOGO_UNSAFE_SVG` | error | SVG contains `<script>`/`<foreignObject>`/animation/external-`href`/`<image>`. |
| `BIMI_LOGO_OVERSIZED` | warn | SVG body > 32 KB; receivers may reject. |
| `BIMI_LOGO_NOT_SQUARE` | warn | `viewBox` is not square; clipped on some clients. |
| `BIMI_VMC_URL_INVALID` | error | `a=` is malformed or not absolute HTTPS. |
| `BIMI_VMC_NOT_HTTPS` | error | `a=` is `http://` — receivers MUST reject. |
| `BIMI_VMC_UNREACHABLE` | error | Fetch of `a=` failed. |
| `BIMI_VMC_EXPIRED` | error | VMC `notAfter` is in the past. |
| `BIMI_VMC_EXPIRING_CRITICAL` | error | VMC expires in ≤14 days. |
| `BIMI_VMC_EXPIRING_SOON` | warn | VMC expires in ≤60 days. |
| `BIMI_VMC_REVOKED` | error | OCSP says `revoked`. |
| `BIMI_VMC_SAN_MISMATCH` | error | VMC SAN doesn't match the queried domain (incl. one-label wildcard). |
| `BIMI_VMC_LOGO_MISMATCH` | error | Logotype hash inside VMC doesn't match the fetched logo. |
| `BIMI_VMC_MISSING_EKU` | error | Leaf VMC lacks the BIMI EKU OID (1.3.6.1.5.5.7.3.31). |
| `BIMI_VMC_ISSUER_MISSING_EKU` | warn | Immediate issuer CA lacks the BIMI EKU. |
| `BIMI_VMC_ISSUER_NOT_ACCEPTED` | warn | Issuer organisation not in DigiCert / Sectigo / Entrust. |
| `BIMI_VMC_NO_EMBEDDED_SCT` | warn | VMC lacks an embedded SCT (RFC 6962). |
| `BIMI_CMC_GMAIL_UNSUPPORTED` | info | `cert_type=cmc`; Gmail's BIMI pipeline only honours VMCs today. |
| `BIMI_DMARC_POLICY_INSUFFICIENT` | error | `p=none` — BIMI prerequisite not met. |
| `BIMI_DMARC_PCT_BELOW_100` | error | `pct<100` — BIMI prerequisite not met. |
| `BIMI_DMARC_SP_NOT_ENFORCED` | warn | `sp=none` — subdomain BIMI display may be inconsistent. |

### MX — `check_mx` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `NO_MX_RECORD` | error | No MX RRset and no fallback A/AAAA. |
| `NULL_MX` | info | `0 .` per RFC 7505 — domain rejects mail. |
| `MX_POINTS_TO_CNAME` | error | Spec disallows. |
| `MX_PRIORITY_INVERTED` | warn | Suspected secondary listed with lower preference than primary. |
| `MX_HOSTNAME_UNRESOLVABLE` | error | MX target NXDOMAIN. |
| `MX_HOST_NO_PTR` | warn | MX target has no PTR record. |
| `MX_TLSA_MISSING` | info | DANE TLSA absent for `_25._tcp.<mx>`. |

### MTA-STS — `check_mta_sts` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `MTA_STS_NO_TXT_RECORD` | info | `_mta-sts.<domain>` TXT not present. |
| `MTA_STS_POLICY_UNREACHABLE` | error | HTTPS GET to `mta-sts.<domain>/.well-known/mta-sts.txt` failed. |
| `MTA_STS_POLICY_INVALID` | error | Policy file parse failure. |
| `MTA_STS_MODE_NONE` | warn | `mode: none` disables enforcement. |
| `MTA_STS_MODE_TESTING` | info | `mode: testing` — enforcement deferred. |
| `MTA_STS_MX_MISMATCH` | error | Live MX hostnames not in policy `mx:` allow-list. |
| `MTA_STS_MAX_AGE_TOO_LOW` | warn | `max_age` under 604800 seconds (7 days). |
| `MULTIPLE_MTA_STS_RECORDS` | error | More than one `_mta-sts` TXT. |

### TLS-RPT — `check_tls_rpt` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `TLS_RPT_NO_RECORD` | info | `_smtp._tls.<domain>` TXT not present. |
| `TLS_RPT_INVALID_VERSION` | error | Missing or non-`v=TLSRPTv1`. |
| `TLS_RPT_NO_RUA` | error | No `rua=` destination. |
| `TLS_RPT_RUA_NOT_REACHABLE` | warn | `rua=` URL fetch / DNS resolution fails. |

### DANE — `check_dane` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `DANE_NO_TLSA` | info | No `_25._tcp.<mx>` TLSA records. |
| `DANE_TLSA_INVALID_USAGE` | error | Usage field outside 0–3. |
| `DANE_TLSA_INVALID_SELECTOR` | error | Selector outside 0–1. |
| `DANE_TLSA_INVALID_MATCHING` | error | Matching type outside 0–2. |
| `DANE_TLSA_MISMATCH_CERT` | error | TLSA matching value doesn't match live TLS cert. |
| `DANE_TLSA_HOST_UNSIGNED` | error | `_25._tcp.<mx>` parent zone not DNSSEC-signed. |
| `DANE_PKIX_TA_USED_WITHOUT_CHAIN` | warn | Usage 0/1 without chain verification. |

### DNSSEC — `check_dnssec` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `DNSSEC_UNSIGNED` | info | No DS at parent; zone is unsigned. |
| `DNSSEC_DS_MISSING_AT_PARENT` | error | DNSKEY present at child but no DS at parent — chain broken. |
| `DNSSEC_DS_ALGORITHM_DEPRECATED` | warn | DS algorithm is SHA-1 or RSA-MD5. |
| `DNSSEC_DNSKEY_ALGORITHM_DEPRECATED` | warn | DNSKEY algorithm deprecated. |
| `DNSSEC_RRSIG_EXPIRED` | error | At least one RRSIG covering a queried RRset is expired. |
| `DNSSEC_BOGUS` | error | Resolver returned SERVFAIL with `AD=0`; signature validation failed. |
| `DNSSEC_AD_BIT_MISSING` | warn | Resolver did not set AD on a signed zone (likely upstream issue). |

### Blacklist — `check_blacklist` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `LISTED_ON_RBL` | error | At least one queried RBL returned a listing. Per-RBL findings carry the zone name in `value`. |
| `LISTED_ON_RBL_COUNT` | error | Aggregate count of RBLs listing this IP/host. `value` = integer. |
| `RBL_QUERY_FAILED` | warn | One or more RBL DNS lookups failed. `value` = comma-separated RBL names. |

### DNS-propagation — `check_dns_propagation` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `RESOLVERS_DISAGREE` | warn | See generic. |
| `RESOLVERS_PARTIAL` | warn | See generic. |
| `PROPAGATION_INCOMPLETE` | info | Authoritative answer differs from public-resolver answer (likely mid-propagation). |

### DNS-lookup — `check_dns_lookup` (Phase 3)

`info` only. No findings except the generic `LOOKUP_FAILED` / `NXDOMAIN`.

### CNAME — `check_cname` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `CNAME_AT_APEX_DISALLOWED` | error | CNAME at the zone apex breaks DNSSEC and SOA serving. |
| `CNAME_DANGLING_TARGET` | error | CNAME resolves to NXDOMAIN. |
| `CNAME_TAKEOVER_SUSPECT` | warn | Target matches a known takeover-suffix (S3, GitHub Pages, etc.). |
| `CNAME_LOOP` | error | CNAME chain loops back on itself. |

### TXT — `check_txt` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `TXT_RECORD_TOO_LONG` | warn | RDATA > 450 bytes. |
| `TXT_MULTIPLE_SPF_LIKE` | error | Multiple TXT strings begin `v=spf1` at the same name. |
| `TXT_VERIFICATION_TOKENS_PRESENT` | info | Detected provider verification tokens (Google site verification etc.). Reference data, not a flaw. |

### Reverse-DNS — `check_reverse_dns` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `NO_PTR_RECORD` | warn | No PTR for the IP. |
| `FCRDNS_MISMATCH` | warn | Forward-confirmed reverse DNS mismatch (PTR target's A/AAAA doesn't include the original IP). |
| `PTR_GENERIC_HOSTNAME` | info | PTR target looks like a cloud-provider generic (`*.compute.amazonaws.com`). |

### Autodiscover — `check_autodiscover` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `AUTODISCOVER_NO_RECORD` | info | No `autodiscover.<domain>` CNAME or A record. |
| `AUTODISCOVER_CNAME_TO_M365` | info | Points to `autodiscover.outlook.com`. |
| `AUTODISCOVER_INSECURE_HTTP` | error | XML endpoint responds on plain HTTP. |
| `AUTODISCOVER_SRV_PRESENT` | info | `_autodiscover._tcp.<domain>` SRV present (Exchange on-prem indicator). |

### M365 / Google Workspace — `check_m365`, `check_google_workspace` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `M365_NOT_DETECTED` | info | No M365 indicators in MX, SPF, autodiscover, MS records. |
| `M365_MX_TO_O365` | info | MX targets `*.mail.protection.outlook.com`. |
| `M365_SPF_MISSING_O365` | warn | M365 detected but SPF doesn't include `spf.protection.outlook.com`. |
| `M365_AUTODISCOVER_MISCONFIGURED` | error | Autodiscover doesn't reach M365. |
| `M365_DKIM_SELECTORS_MISSING` | warn | `selector1._domainkey` / `selector2._domainkey` not configured. |
| `GWS_NOT_DETECTED` | info | No Google Workspace indicators. |
| `GWS_MX_TO_GOOGLE` | info | MX targets `*.googlemail.com` / `aspmx.l.google.com`. |
| `GWS_SPF_MISSING_GOOGLE` | warn | GWS detected but SPF doesn't include `_spf.google.com`. |
| `GWS_DKIM_SELECTORS_MISSING` | warn | `google._domainkey` not configured. |

### WHOIS — `check_whois` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `WHOIS_PRIVACY_PROTECTED` | info | Registrant fields are redacted / privacy-proxy listed. |
| `WHOIS_NEAR_EXPIRY` | warn | Domain expires within 30 days. |
| `WHOIS_EXPIRED` | error | Expiry date is in the past. |
| `WHOIS_RDAP_PARTIAL` | warn | RDAP endpoint returned an incomplete object (e.g. `.io` NS gaps). |

### Website — `check_website` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `TLS_CERT_EXPIRED` | error | Live TLS cert `notAfter` is past. |
| `TLS_CERT_NEAR_EXPIRY` | warn | Live TLS cert expires within 30 days. |
| `TLS_CERT_HOSTNAME_MISMATCH` | error | SAN doesn't cover the hostname. |
| `REDIRECT_LOOP` | error | More than 10 redirects, or two redirects forming a cycle. |
| `MIXED_CONTENT` | warn | HTTPS page references HTTP subresources. |
| `HSTS_MISSING` | warn | No `Strict-Transport-Security` header. |
| `HSTS_SHORT_MAX_AGE` | warn | HSTS `max-age` under 15768000 (~6 months). |
| `WWW_NOT_REDIRECTED` | warn | `www.<domain>` doesn't redirect to apex (or vice versa). |
| `HTTP_NOT_REDIRECTED_TO_HTTPS` | error | Plain `http://<domain>` doesn't 301/308 to `https://`. |

### MCP-keys — `check_mcp_keys` (Phase 3)

| Code | Severity | Meaning |
|---|---|---|
| `MCP_KEYS_NO_RECORD` | info | No `v=MCPv1` TXT at the apex. |
| `MCP_KEYS_INVALID_VERSION` | error | TXT present but `v=` is not `MCPv1`. |
| `MCP_KEYS_DUPLICATE_KID` | error | Two key entries with the same `kid=`. |
| `MCP_KEYS_EXPIRED` | warn | At least one key has `exp=` in the past. |
| `MCP_KEYS_ALGORITHM_WEAK` | warn | Algorithm not in the strong-set (Ed25519, RSA-2048+, ECDSA P-256+). |

## `error.code` namespace

When a tool emits the optional `error` object (resolver timeout,
unparseable record), the `error.code` field uses the same registry but
in the reserved namespace below — these are NEVER emitted as
`findings[].code`:

| Code | Meaning |
|---|---|
| `DOH_TIMEOUT` | All DoH resolvers timed out. Retriable. |
| `DOH_CONSENSUS_FAILED` | Resolvers returned but didn't agree on anything parseable. |
| `INPUT_INVALID` | Input failed pre-flight sanitisation (typically caught at the AJAX layer). |
| `INTERNAL_ERROR` | Class threw uncaught. Non-retriable until fix. |

## What's NOT a finding code

- Render-only chip text (e.g. "Approaching limit" — that's a `title`,
  not a code).
- Free-form messages from the AJAX layer (caught and folded into
  `error.message`).
- Provider-matrix flags inside BIMI's `provider_matrix` sibling
  (those are renderer-only).

## Cross-referencing

The MCP server publishes this registry as a `resources/read`-callable
resource at `mcp://tamingdns/resources/finding-codes`. Agents can list
it via `resources/list` and read the latest version without scraping
this markdown file.
