{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://artifacts.apis.io/inboxguard-io/json-schema/inboxguard-io-scan-result-schema.json",
  "title": "ScanResult",
  "description": "Scan response. The per-check objects are TOP-LEVEL properties — there is no `checks` wrapper and no per-check {status, summary} shape.",
  "x-generated": "2026-11-12",
  "x-method": "derived",
  "x-generator": "derive-json-schema.py",
  "x-source": "openapi/inboxguard-io-openapi.json#/components/schemas/ScanResult",
  "type": "object",
  "properties": {
    "domain": {
      "type": "string"
    },
    "score": {
      "$ref": "#/$defs/ScanScore"
    },
    "scoringVersion": {
      "type": "integer",
      "description": "Version of the scoring model that produced `score`."
    },
    "spf": {
      "$ref": "#/$defs/DomainCheck"
    },
    "dmarc": {
      "$ref": "#/$defs/DomainCheck"
    },
    "ptr": {
      "$ref": "#/$defs/DomainCheck"
    },
    "dkim": {
      "$ref": "#/$defs/DomainCheck"
    },
    "mtaSts": {
      "$ref": "#/$defs/DomainCheck"
    },
    "tlsRpt": {
      "$ref": "#/$defs/DomainCheck"
    },
    "mxTls": {
      "$ref": "#/$defs/DomainCheck"
    },
    "bimi": {
      "$ref": "#/$defs/DomainCheck"
    },
    "blocklist": {
      "$ref": "#/$defs/DomainCheck"
    },
    "dnssec": {
      "$ref": "#/$defs/DnssecResult"
    },
    "remediations": {
      "type": "array",
      "description": "Per-failing-check fix list (critical first). Each item is the concrete next action — the exact DNS record to publish plus whether the registrar auto-fix engine can apply it. Healthy checks contribute nothing, so this is empty when the domain is clean.",
      "items": {
        "$ref": "#/$defs/Remediation"
      }
    },
    "durationMs": {
      "type": "integer"
    },
    "plan": {
      "type": "object",
      "properties": {
        "tier": {
          "type": "string",
          "description": "public (anonymous), free, entry, pro, or agency."
        },
        "trialing": {
          "type": "boolean"
        },
        "blocklistsChecked": {
          "description": "Number of blocklists checked, or 'all'.",
          "anyOf": [
            {
              "type": "integer"
            },
            {
              "type": "string"
            }
          ]
        }
      }
    }
  },
  "required": [
    "domain",
    "score",
    "scoringVersion",
    "spf",
    "dmarc",
    "ptr",
    "dkim",
    "mtaSts",
    "tlsRpt",
    "mxTls",
    "bimi",
    "blocklist",
    "dnssec",
    "remediations",
    "durationMs",
    "plan"
  ],
  "$defs": {
    "ComponentScore": {
      "type": "object",
      "description": "Scoring model v3 (2026-08-05) per-check applicability verdict. 'scored' = a real 0..weight number, exactly as before. 'not_applicable' = this check doesn't apply to this domain (e.g. MTA-STS/TLS-RPT on a domain confirmed to publish no MX) — excluded from both the score's numerator and denominator; render as neutral, remediation suppressed. 'undetermined' = couldn't be determined this scan (a DNS lookup failure, or — DKIM only — an ESP that mints an unenumerable per-tenant selector, e.g. Amazon SES Easy DKIM); same exclusion as not_applicable, but render as neutral/\"Unverified\", NEVER as a pass or failure. See https://inboxguard.io/llms-full.txt#scoring.",
      "properties": {
        "state": {
          "type": "string",
          "enum": [
            "scored",
            "not_applicable",
            "undetermined"
          ]
        },
        "earned": {
          "type": "number",
          "description": "Points earned this scan. 0 whenever state != 'scored'."
        },
        "max": {
          "type": "number",
          "description": "Points counted toward the renormalized denominator this scan. 0 whenever state != 'scored'."
        },
        "weight": {
          "type": "number",
          "description": "The check's nominal/full weight, unaffected by applicability."
        },
        "reason": {
          "type": "string",
          "enum": [
            "no_mx",
            "null_mx",
            "esp_selectors_not_enumerable",
            "dns_lookup_failed",
            "check_not_run"
          ],
          "description": "Present only for not_applicable/undetermined."
        }
      },
      "required": [
        "state",
        "earned",
        "max",
        "weight"
      ]
    },
    "DnssecResult": {
      "type": "object",
      "description": "DNSSEC + DANE/TLSA detection (INFORMATIONAL — does not feed the score). Always present on the live /scan-domain response.",
      "properties": {
        "domain": {
          "type": "string"
        },
        "dsPresent": {
          "type": "boolean",
          "description": "DS record present at the parent — the chain of trust is anchored."
        },
        "dnskeyPresent": {
          "type": "boolean",
          "description": "Zone publishes DNSKEY records."
        },
        "enabled": {
          "type": "boolean",
          "description": "True only when the zone is signed AND anchored (DS + DNSKEY). DNSKEY without DS is treated as unsigned by validating receivers."
        },
        "dane": {
          "type": "array",
          "description": "Per-MX DANE/TLSA presence (TLSA at _25._tcp.<mxhost>). Empty when the domain has no MX.",
          "items": {
            "type": "object",
            "properties": {
              "mxHost": {
                "type": "string"
              },
              "tlsaPresent": {
                "type": "boolean"
              }
            },
            "required": [
              "mxHost",
              "tlsaPresent"
            ]
          }
        },
        "daneEnabled": {
          "type": "boolean",
          "description": "True when at least one MX host advertises DANE TLSA."
        },
        "checkError": {
          "type": "boolean",
          "description": "DNS resolution temp-failed; the result is \"unknown\", not \"off\". Re-scan to confirm."
        },
        "issues": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/Issue"
          }
        }
      },
      "required": [
        "domain",
        "dsPresent",
        "dnskeyPresent",
        "enabled",
        "dane",
        "daneEnabled",
        "checkError",
        "issues"
      ]
    },
    "DomainCheck": {
      "type": "object",
      "description": "Result of one authentication/transport/reputation check. Carries `healthy` (boolean) and `issues` (array of {severity, message}) plus check-specific fields — e.g. SPF `record`/`lookupCount`/`allQualifier`/`permerrors`, DMARC `policy`/`pct`/`rua`, DKIM `keys[]` (selector, keyLengthBits, …), PTR `mxIps[]`, MTA-STS `policy`, BIMI `logoUrl`/`vmcUrl`, blocklist `hits[]`/`targets[]`/`checked`/`errored`. Exceptions: `mxTls` has `status` ('unsupported'|'pass'|'fail'|'partial') instead of `healthy` plus `basis` ('live'|'policy'|'none') describing how the verdict was reached — when an outbound STARTTLS probe isn't available the verdict is derived from MTA-STS/DANE policy (basis 'policy'); and `bimi` uses `found`/`logoReachable`/`displayEligible`.",
      "properties": {
        "healthy": {
          "type": "boolean",
          "description": "Whether this check passed cleanly (absent on mxTls and bimi — see description)."
        },
        "status": {
          "type": "string",
          "enum": [
            "unsupported",
            "pass",
            "fail",
            "partial"
          ],
          "description": "mxTls only — the transport-TLS verdict (used instead of `healthy`)."
        },
        "basis": {
          "type": "string",
          "enum": [
            "live",
            "policy",
            "none"
          ],
          "description": "mxTls only — how `status` was determined: 'live' (real STARTTLS handshake), 'policy' (derived from MTA-STS enforce/testing or DANE when a live probe isn't available — needs no outbound port 25), or 'none' (no probe and no enforcing policy)."
        },
        "issues": {
          "type": "array",
          "items": {
            "$ref": "#/$defs/Issue"
          },
          "description": "Findings, each with severity and message."
        }
      },
      "additionalProperties": true
    },
    "Issue": {
      "type": "object",
      "description": "One finding inside a check.",
      "properties": {
        "severity": {
          "type": "string",
          "enum": [
            "info",
            "warn",
            "critical"
          ]
        },
        "message": {
          "type": "string"
        }
      },
      "required": [
        "severity",
        "message"
      ]
    },
    "Remediation": {
      "type": "object",
      "description": "One concrete fix for a failing/weak check, derived from the structured scan result.",
      "properties": {
        "check": {
          "type": "string",
          "enum": [
            "spf",
            "dmarc",
            "dkim",
            "ptr",
            "mta-sts",
            "tls-rpt",
            "bimi",
            "blocklist",
            "dnssec"
          ]
        },
        "code": {
          "type": "string",
          "description": "Stable machine code, e.g. 'dmarc_missing'."
        },
        "severity": {
          "type": "string",
          "enum": [
            "critical",
            "warn",
            "info"
          ]
        },
        "title": {
          "type": "string"
        },
        "summary": {
          "type": "string"
        },
        "steps": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "record": {
          "$ref": "#/$defs/RemediationRecord"
        },
        "links": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "label": {
                "type": "string"
              },
              "href": {
                "type": "string",
                "format": "uri"
              }
            },
            "required": [
              "label",
              "href"
            ]
          }
        },
        "effort": {
          "type": "string",
          "enum": [
            "5 min",
            "15 min",
            "30 min",
            "1 hour"
          ]
        },
        "autoFixable": {
          "type": "boolean",
          "description": "True when the registrar auto-fix engine (POST /domains/{id}/dns-apply) can publish this without customer input. SPF/DKIM/BIMI are guidance-only (false) even though a record is shown; a MISSING DMARC, the MTA-STS version TXT, and TLS-RPT are auto-fixable."
        }
      },
      "required": [
        "check",
        "code",
        "severity",
        "title",
        "summary",
        "steps",
        "autoFixable"
      ]
    },
    "RemediationRecord": {
      "type": "object",
      "description": "The exact DNS record to publish for a fix. Kept byte-for-byte identical to what the registrar auto-fix engine would write.",
      "properties": {
        "host": {
          "type": "string",
          "description": "Record name/host, e.g. '@' for the apex or '_dmarc.example.com'."
        },
        "type": {
          "type": "string",
          "enum": [
            "TXT",
            "CNAME",
            "MX"
          ]
        },
        "value": {
          "type": "string"
        },
        "note": {
          "type": "string",
          "description": "Optional caveat (e.g. replace placeholder senders, also host the MTA-STS policy file)."
        }
      },
      "required": [
        "host",
        "type",
        "value"
      ]
    },
    "ScanScore": {
      "type": "object",
      "description": "Overall deliverability score with a per-check point breakdown.",
      "properties": {
        "total": {
          "type": "integer",
          "minimum": 0,
          "maximum": 100,
          "description": "Overall 0–100 score, renormalized over what's actually applicable (total = round(100 * earnedTotal / applicableMax))."
        },
        "grade": {
          "type": "string",
          "enum": [
            "A",
            "B",
            "C",
            "D",
            "F"
          ]
        },
        "breakdown": {
          "type": "object",
          "description": "Points contributed by each check.",
          "properties": {
            "spf": {
              "type": "number"
            },
            "dmarc": {
              "type": "number"
            },
            "ptr": {
              "type": "number"
            },
            "dkim": {
              "type": "number"
            },
            "bimi": {
              "type": "number"
            },
            "mtaSts": {
              "type": "number"
            },
            "tlsRpt": {
              "type": "number"
            },
            "blocklist": {
              "type": "number"
            }
          }
        },
        "components": {
          "type": "object",
          "description": "Scoring model v3 (2026-08-05) — per-check applicability state. Absent on a cached response from before that date; treat absence exactly like every check being 'scored'.",
          "properties": {
            "spf": {
              "$ref": "#/$defs/ComponentScore"
            },
            "dmarc": {
              "$ref": "#/$defs/ComponentScore"
            },
            "ptr": {
              "$ref": "#/$defs/ComponentScore"
            },
            "dkim": {
              "$ref": "#/$defs/ComponentScore"
            },
            "bimi": {
              "$ref": "#/$defs/ComponentScore"
            },
            "mtaSts": {
              "$ref": "#/$defs/ComponentScore"
            },
            "tlsRpt": {
              "$ref": "#/$defs/ComponentScore"
            },
            "blocklist": {
              "$ref": "#/$defs/ComponentScore"
            }
          }
        },
        "earnedTotal": {
          "type": "number",
          "description": "Sum of `earned` across every `scored` component."
        },
        "applicableMax": {
          "type": "number",
          "description": "Sum of `max` across every `scored` component — `total`'s denominator."
        },
        "undeterminedMax": {
          "type": "number",
          "description": "Sum of `weight` across every `undetermined` component."
        },
        "degraded": {
          "type": "boolean",
          "description": "True when any component is `undetermined` because of an actual DNS resolver failure this scan (not a stable ESP-detection state) — a score delta on a degraded scan is scan-quality noise, not a real change."
        }
      },
      "required": [
        "total",
        "grade",
        "breakdown"
      ]
    }
  }
}