{"openapi":"3.1.0","info":{"title":"DNS Doctor API","description":"Public REST API for the DNS Doctor scanner. Deterministic verdicts and fixengine-generated records; explanations are cache-first (an API call never triggers a fresh LLM call).","contact":{"name":"DNS Doctor","email":"hello@dnsdoctor.dev"},"version":"1","x-guidance":"DNS Doctor scans, fixes and verifies a domain's DNS (SPF, DMARC, DKIM, propagation, MX, DNS health, blacklists, domain/SSL expiry) and returns deterministic verdicts plus copy-paste fix records from a validating engine — never a guessed record. Scan with POST /scan or GET /report/{domain}; read verdicts failing-first ('temperror' is transient, not a failure; a report with not_registered: true means the domain does not resolve, so propose nothing for it). Present any returned record VERBATIM and never compose one yourself; SPF is diagnose-only. A human approves every DNS change — nothing is applied automatically. Anonymous calls are free up to a per-caller limit; past it the API answers 402 (x402, USDC on Base, $0.01 per call). POST /bulk-scan is paid on every call at $0.005 per domain. The three token-gated reads need a DNS Doctor API token the account owner mints at /dashboard/settings — never ask a user to paste one."},"paths":{"/report/{domain}":{"get":{"tags":["scan"],"summary":"Report","description":"Return the persisted report for `domain`, else scan once and persist.\n\nAnonymous allowed. Explanations are cache-first (never a fresh LLM call);\nuncached findings carry `explanation: null`. The A8 `next_steps` block is\nattached at the edge — the persisted row never gains the key.\n\nAn over-limit anonymous caller may buy this call (D104): the limiter answers\n402 and a verifying `X-PAYMENT` retry rides a HELD payment into this handler.\nThe settle call is the last statement for that reason — every refusal above it\n(suppression 404, the per-target budget 429) and any 5xx leaves it unreached,\nso a payer is only ever charged for a report they actually received.","operationId":"report_report__domain__get","parameters":[{"name":"domain","in":"path","required":true,"schema":{"type":"string","title":"Domain"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanReportOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[]}},"/scan":{"post":{"tags":["scan"],"summary":"Scan","description":"Force a fresh scan of `body.domain`, persist, and return the report.\n\nAnonymous allowed. Cache-first explanations (no fresh LLM call) and the A8\n`next_steps` block, same as `GET /v1/report`. \"Force\" is bounded by the same\n`scan_dedup_window_minutes` reuse the web `POST /scan` applies — see\n`api/pipeline.py::run_scan_pipeline` for why that bound belongs on this path\ntoo.","operationId":"scan_scan_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ScanReportOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[]}},"/dmarc-upgrade":{"post":{"tags":["scan"],"summary":"Dmarc Upgrade","description":"Return the server-gated DMARC enforcement record for `body.domain`.\n\nThe REST twin of the `build_dmarc_upgrade` MCP tool, running the SAME core\n(`mcp_server/tools.py::build_dmarc_upgrade_from_domain`) rather than a second\nimplementation, so the two agent transports cannot drift: the record is\n`build_upgrade_record` output byte for byte, and the ceiling is\n`p=quarantine`, unlocked by a **server-side** alignment gate — a caller\ncannot assert alignment to buy a stronger policy, and `p=reject` is not\nreachable from any scan (D101: only the readiness engine's aggregate-report\nevidence unlocks it).\n\nAnonymous allowed. Scans fresh (see the core's docstring for why a stale\nbaseline is silently destructive here), which is why this path carries the\nsame `enforce_v1_rate_limit` and per-target scan budget the scan routes do.\n\n`record: null` is an ANSWER, not an error: the domain does not exist, DMARC\ncould not be read, the published policy is already at least as strong as\nthis scan justifies, or the scan observed no alignment signal at all — the\nD101 reporting-first refusal, whose `rationale` asks for `rua=` reporting\nrather than offering a weaker record. Relay `rationale`; never compose a\nrecord to fill it.\n\nError mapping — the transport-neutral core errors become HTTP:\n`ToolInputError` → 422 with the core's own detail, `ToolTransientError` →\n503 (retrying the identical call is the right response), and the pipeline's\nper-target budget → the same 429 both scan routes answer with. Suppression is\nrefused here first, with the byte-identical 404 an unscanned domain gets, so\nthe core's own suppression branch (a `ToolInputError`, which would surface as\na 422) is never reached.","operationId":"dmarc_upgrade_dmarc_upgrade_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DmarcUpgradeIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DmarcUpgradeOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[]}},"/signup-url":{"post":{"tags":["scan"],"summary":"Signup Url","description":"Return the monitoring signup hand-off for `body.domain`.\n\nThe REST twin of the `start_monitoring_signup` MCP tool, running the SAME pure\ncore (`mcp_server/signup.py::build_signup_url`). It performs **no I/O of any\nkind** — no DNS, no store read, no mail: it canonicalizes the domain and\nassembles two URLs rooted at `settings.public_base_url`. Nothing is created\nuntil the human opens `signup_url` and signs in there themselves.\n\n**No suppression check, deliberately** — the same decision `signup.py`\ndocuments for the tool, not an oversight. The valve is a store read, and the\nno-I/O property is what makes this surface's closed-world claim true; nothing\nleaks by skipping it, because suppression withholds a *report* and both\nreturned URLs still refuse on their own (`/scan/{domain}` is served by the\nsuppression-aware pipeline, and `/start` publishes nothing about the domain —\nit prefills a name the caller already had).\n\nAnonymous allowed. `ToolInputError` → 422 with the core's own detail, matching\nthe `normalize_domain` contract the other v1 routes answer with.","operationId":"signup_url_signup_url_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupUrlIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupUrlOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[]}},"/domains":{"get":{"tags":["domains"],"summary":"Domains","description":"List the token's account's verified monitored domains + latest statuses.\n\nToken required (401 without one). Each domain carries the latest\n`scan_history` snapshot's per-check statuses + `scanned_at` — never a letter\ngrade (re-validation #8). A verified domain with no snapshot yet returns\n`scanned_at: null` and empty `checks`.","operationId":"domains_domains_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DomainsOut"}}}}},"security":[{"apiKey":[]}]}},"/alerts":{"get":{"tags":["monitoring"],"summary":"Alerts","description":"The token account's monitoring alert log, newest first. Token required.\n\nFilters: `since` (an INCLUSIVE ISO-8601 `created_at` floor — poll by storing\nthe newest `created_at` seen and passing it back; rows repeat rather than go\nmissing, so dedupe on `id`; 422 on an unparseable value, and any valid ISO\nrendering works — it is normalized to the stored UTC form before comparing),\n`domain` (must be one of the caller's verified domains, else the same opaque\n404 the rest of the surface gives — ownership is never leaked), `type` (422 on\nan unknown value, never a silent empty page), `limit` (1..100), `before` (the\nopaque `created_at,id` keyset cursor from a previous page's `next_before`,\n422 malformed).\n\n**Page down before advancing `since`.** `next_before` is non-null exactly\nwhen older rows remain in this window; a caller that ignores it, takes a full\npage and moves its watermark to the newest row it saw drops every row it did\nnot receive — silently, which is the failure this feed exists to prevent.\n\n**READ-ONLY, and that is a product decision, not an omission**: there is no\nack, no delete, and no mutation of any kind here. Acking is the human's own\ntriage state on their dashboard — an agent that acks on their behalf silences\na row the human has never seen, which is precisely the failure this feed\nexists to prevent. Both surfaces read the same rows; only the human clears\nthem (design D87 §Part 1).\n\nEvery row carries `delivery_class`, so an agent sees the rows the D86 tiering\nrouted `dashboard_only` — the ones deliberately kept out of the digest mail.\nAn agent watching only the inbox would miss them; that log parity is the point\nof the endpoint.\n\nNo plan gate, deliberately: read-only-degrade (D42) keeps alert history\nreadable on a lapsed plan exactly as the session-authed `/monitor/alerts` and\nthe per-domain history reads do. What lapsing stops is scans, alerts and new\nRUA ingestion — never a read of data the account already has.","operationId":"alerts_alerts_get","parameters":[{"name":"since","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Since"}},{"name":"domain","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Domain"}},{"name":"type","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Type"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"before","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Before"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AlertsOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"apiKey":[]}]}},"/readiness":{"get":{"tags":["monitoring"],"summary":"Readiness","description":"The DMARC enforcement-readiness verdict for one of the caller's domains.\n\nToken required. `domain` must be a verified domain the token's account owns —\nanything else is the same opaque 404 the rest of the surface gives, whether it\nbelongs to another account or to nobody (the gate is the SHARED\n`api/monitor.py::require_owned_verified_row`, so this read and the dashboard's\nown answer the ownership question from one definition). A malformed domain is\na 422 from `normalize_domain`, consistent with every other v1 path.\n\nThe body is the session-authed `/rua/domains/{id}/readiness` body, field for\nfield: the same `readiness_service.readiness_for_domain` computation and the\nsame `serialize_readiness` mapping, so the agent and the human can never be\ntold different things about the same window. `next_record` is\n`ReadinessVerdict.next_record` VERBATIM — only ever `build_upgrade_record`\noutput, never composed here and never the LLM (the invariant) — and it is\nnull while blocked, which is an ANSWER: the blockers say why, and a caller\nmust never compose a stronger record to fill the gap.\n\nRead-only, and it takes no plan gate: D42 read-only-degrade keeps a lapsed\naccount's own data readable. Lapsing stops scans, alerts and new RUA\ningestion; the verdict over already-ingested reports is not new ingestion.\nThe one live read it can perform is the bounded DMARC TXT fallback inside\n`current_dmarc_record` (only when no stored scan carries the record), which is\na read of the caller's own published DNS.","operationId":"readiness_readiness_get","parameters":[{"name":"domain","in":"query","required":true,"schema":{"type":"string","title":"Domain"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReadinessOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[{"apiKey":[]}]}},"/bulk-scan":{"post":{"tags":["scan"],"summary":"Bulk Scan","description":"Scan 2-50 domains in one paid call. Anonymous; **402 on every call**.\n\nPriced per domain, computed from the batch — the 402 quotes the price for\nTHIS request. Body validation runs first, so a malformed batch is a 422 that\ncosts nothing; an inert deployment (no x402 credentials) answers 503, because\na surface that can only be sold cannot be given away.\n\n`_UNPAID_OFFER_LIMIT` bounds the two shapes the lane's own budget never sees:\na request with no payment header, and one whose body is refused before the\nlane opens. The lane meters only headers it had to verify, so both would\notherwise be unmetered on the one route with no limiter dependency. The\nheader-less half is spent in `bound_unpaid_request` — a route dependency, so\na body pydantic refuses on its own is on the counted path too.","operationId":"bulk_scan_bulk_scan_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkScanIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkScanOut"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"402":{"description":"Payment required — retry with an x402 payment header."}},"x-payment-info":{"payTo":"0x6fDc1f22520151DfBc6273d6C48E413D434C7916","price":{"mode":"dynamic","currency":"USD","min":"0.01","max":"0.25"},"protocols":[{"x402":{}}],"network":"base"}}},"/watch/propagation":{"post":{"tags":["watch"],"summary":"Create Propagation Watch","description":"Buy a propagation watch: one name, six vantage points, up to 24 hours.\n\nAnonymous; **402 on every call**. Gate order is the contract — inert 503,\nthen the 422 on a malformed target, then the fleet-wide caps' 429, and only\nthen the lane. Everything that can refuse refuses before any money moves, and\nthe payment is settled only once the row is committed.","operationId":"create_propagation_watch_watch_propagation_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WatchPropagationIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WatchCreated"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"402":{"description":"Payment required — retry with an x402 payment header."}},"x-payment-info":{"payTo":"0x6fDc1f22520151DfBc6273d6C48E413D434C7916","price":{"mode":"dynamic","currency":"USD","min":"0.004167","max":"0.1"},"protocols":[{"x402":{}}],"network":"base"}}},"/watch/ping":{"post":{"tags":["watch"],"summary":"Create Ping Watch","description":"Buy an HTTP ping watch: one URL, five regions on four continents, up to 24 hours.\n\nSame gate order as the propagation create, with one gate more: the URL is put\nthrough the SSRF seam (structural rules, then resolve-and-pin) BEFORE the lane\nopens, so a target we would never dispatch five hosts at is a free 422. The\nper-check re-resolve in `watch/ping.py` is what closes the rebinding window\nafterwards — this pre-flight only keeps an unbuyable target from being sold.","operationId":"create_ping_watch_watch_ping_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WatchPingIn"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WatchCreated"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}},"402":{"description":"Payment required — retry with an x402 payment header."}},"x-payment-info":{"payTo":"0x6fDc1f22520151DfBc6273d6C48E413D434C7916","price":{"mode":"dynamic","currency":"USD","min":"0.004167","max":"0.25"},"protocols":[{"x402":{}}],"network":"base"}}},"/watch/{watch_id}":{"get":{"tags":["watch"],"summary":"Poll Watch","description":"Read one bought watch. Free, per-handle rate-limited, 404 on anything else.\n\n`before` pages backwards through the observations (newest first, 50 a page);\nthe response's `next_before` is non-null exactly when older ones remain.","operationId":"poll_watch_watch__watch_id__get","parameters":[{"name":"watch_id","in":"path","required":true,"schema":{"type":"string","title":"Watch Id"}},{"name":"before","in":"query","required":false,"schema":{"anyOf":[{"type":"string","maxLength":40},{"type":"null"}],"title":"Before"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WatchStatus"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"security":[]}}},"components":{"schemas":{"AlertOut":{"properties":{"id":{"type":"integer","title":"Id"},"domain":{"type":"string","title":"Domain"},"type":{"type":"string","title":"Type"},"check":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Check"},"summary":{"type":"string","title":"Summary"},"detail":{"additionalProperties":{"type":"string"},"type":"object","title":"Detail"},"created_at":{"type":"string","title":"Created At"},"email_sent_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email Sent At"},"acknowledged_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Acknowledged At"},"delivery_class":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Delivery Class"},"kind":{"type":"string","title":"Kind"}},"type":"object","required":["id","domain","type","check","summary","detail","created_at","email_sent_at","acknowledged_at","delivery_class","kind"],"title":"AlertOut","description":"One alert row on the agent feed — the dashboard row plus `delivery_class`.\n\n`kind` (D109) is the stored attention/activity dimension — the same one the\ndashboard's tabs read, so an agent sees the same triage the human does. It is\na RESPONSE field only: this feed takes no `kind` filter and the MCP twin takes\nno `kind` argument, deliberately (a feed that can be told to hide a class of\nrow is a feed that can silently drop an alert, and `tools/list` stays pinned).\n\n`delivery_class` is the D86 routing verdict and is the field that makes this\nsurface worth having: at an observed `p=reject` an aligned new source is\nrouted `dashboard_only` and never mailed, so an agent watching the inbox sees\na quieter stream than the log holds. Null means digest-eligible (the default\nfor every row the tiering never classified).\n\n`detail` is `alerts.detail_json` parsed to a flat string map by the same\n`parse_alert_detail` the dashboard route uses — deterministic old/new/fix\nfields written by the rules engine. LLM prose never reaches `detail_json` by\nconstruction, so this surface inherits the record invariant unchanged."},"AlertsOut":{"properties":{"alerts":{"items":{"$ref":"#/components/schemas/AlertOut"},"type":"array","title":"Alerts"},"next_before":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Before"}},"type":"object","required":["alerts","next_before"],"title":"AlertsOut","description":"The `GET /v1/alerts` response: newest-first rows for the token's account.\n\n`next_before` is the opaque `created_at,id` keyset cursor for the next (older)\npage, or null when this page is the last one. It is NOT decoration: without\nit a caller that polls with `since` and receives a full page has no way to\nknow rows were cut off, and advancing its watermark to the newest row it saw\nskips every older row in that window permanently — the one thing this feed\nexists not to do. Page down with `before` until `next_before` is null, THEN\nadvance `since`. Same format as the session-authed log's cursor (one\nencoder, `api/monitor.py::encode_alert_cursor`)."},"BlockerOut":{"properties":{"source":{"type":"string","title":"Source"},"metric":{"type":"string","title":"Metric"},"fix_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fix Hint"},"ip":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Ip"},"senders":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Senders"},"ips":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Ips"}},"type":"object","required":["source","metric","fix_hint"],"title":"BlockerOut","description":"One reason the next policy step stays locked."},"BulkScanIn":{"properties":{"domains":{"items":{"type":"string","maxLength":253},"type":"array","maxItems":50,"title":"Domains"}},"type":"object","required":["domains"],"title":"BulkScanIn","description":"The request body: 2-50 domains, canonicalized and deduped server-side.\n\nThe upper bound is enforced TWICE, and both layers earn their place. At the\nmodel (below) so pydantic refuses an oversized array during parse; and again\nin `_normalize_batch`, which is what applies the bound to the DEDUPED count —\nthe number actually scanned and priced.\n\n⚠️ This docstring used to say the model-level bound was deliberately absent,\non the reasoning that a pydantic-raised 422 skips the handler and so escapes\nmetering. That reasoning was answered by `_metered_validation_handler`, which\nmeters exactly those refusals, and the bound was added by the 2026-09-05\nreview — a caller with a junk payment header could otherwise force a large\nallocation before anything counted it. Metering no longer depends on where\nthe refusal is raised, so the bound belongs at the earliest layer."},"BulkScanOut":{"properties":{"domains_requested":{"type":"integer","title":"Domains Requested"},"domains_scanned":{"type":"integer","title":"Domains Scanned"},"results":{"items":{"$ref":"#/components/schemas/BulkScanResult"},"type":"array","title":"Results"}},"type":"object","required":["domains_requested","domains_scanned","results"],"title":"BulkScanOut","description":"The batch response. `domains_scanned` is what the settle gate reads."},"BulkScanResult":{"properties":{"domain":{"type":"string","title":"Domain"},"status":{"type":"string","enum":["ok","error"],"title":"Status"},"report":{"anyOf":[{"$ref":"#/components/schemas/ScanReportOut"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["domain","status"],"title":"BulkScanResult","description":"One domain's outcome. `report` and `error` are mutually exclusive.\n\nAn entry is never silently dropped: a caller who paid for fifty names gets\nfifty entries, so \"missing\" can never be mistaken for \"clean\"."},"CheckResult":{"properties":{"check":{"type":"string","title":"Check"},"status":{"$ref":"#/components/schemas/CheckStatus"},"title":{"type":"string","title":"Title"},"raw":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Raw"},"explanation":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Explanation"},"fix_record":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Fix Record"},"details":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Details"}},"type":"object","required":["check","status","title"],"title":"CheckResult","description":"The outcome of one check (e.g. SPF, DMARC, expiry)."},"CheckStatus":{"type":"string","enum":["pass","warn","fail","info","temperror"],"title":"CheckStatus"},"DmarcUpgradeIn":{"properties":{"domain":{"type":"string","title":"Domain"}},"type":"object","required":["domain"],"title":"DmarcUpgradeIn","description":"The `POST /v1/dmarc-upgrade` body — a bare domain, same shape as `ScanIn`."},"DmarcUpgradeOut":{"properties":{"domain":{"type":"string","title":"Domain"},"record":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record"},"policy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Policy"},"current_policy":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Policy"},"alignment_ok":{"type":"boolean","title":"Alignment Ok"},"rationale":{"type":"string","title":"Rationale"},"apply_note":{"type":"string","title":"Apply Note"}},"type":"object","required":["domain","record","policy","current_policy","alignment_ok","rationale","apply_note"],"title":"DmarcUpgradeOut","description":"The `POST /v1/dmarc-upgrade` response — the MCP tool result, key for key.\n\nDeclared explicitly (rather than left as a bare dict) so `/openapi.json`\ndocuments the real shape, including the nullability that carries the\ncontract: `record` is null on every honest refusal — the answer is then\n`rationale`, and a caller must NEVER compose a record to fill the gap.\n`policy` names the policy the RETURNED record sets, so it is null exactly\nwhen `record` is; the domain's observed policy travels in `current_policy`\n(also null when no DMARC record could be read).\n\nThe field set mirrors `mcp_server/tools.py::build_dmarc_upgrade_tool` — the\nsame core both surfaces run — so the REST body is byte-equal to the MCP tool\nresult by construction. A key added there is added here."},"DomainCheck":{"properties":{"check":{"type":"string","title":"Check"},"status":{"type":"string","title":"Status"}},"type":"object","required":["check","status"],"title":"DomainCheck","description":"One check of a domain's latest snapshot — name + status only.\n\nDeliberately NO letter grade: the A-F grade is `frontend/lib/grade.ts`, a\nversioned frontend contract. Recomputing it server-side would duplicate the\nrubric and drift; the API returns the raw per-check statuses instead."},"DomainOut":{"properties":{"domain":{"type":"string","title":"Domain"},"scanned_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Scanned At"},"checks":{"items":{"$ref":"#/components/schemas/DomainCheck"},"type":"array","title":"Checks"}},"type":"object","required":["domain","scanned_at","checks"],"title":"DomainOut","description":"A monitored domain with its latest scan snapshot's per-check statuses.\n\n`scanned_at` is null (and `checks` empty) when the domain is verified but has\nno snapshot yet — awaiting its first monitored scan, not an error."},"DomainsOut":{"properties":{"domains":{"items":{"$ref":"#/components/schemas/DomainOut"},"type":"array","title":"Domains"}},"type":"object","required":["domains"],"title":"DomainsOut","description":"The `GET /v1/domains` response: the account's verified monitored domains."},"EnrollIn":{"properties":{"email":{"type":"string","title":"Email"},"domain":{"type":"string","title":"Domain"}},"type":"object","required":["email","domain"],"title":"EnrollIn","description":"The `POST /v1/enroll` body — the human's email and the domain to watch."},"EnrollOut":{"properties":{"status":{"type":"string","title":"Status"},"message":{"type":"string","title":"Message"}},"type":"object","required":["status","message"],"title":"EnrollOut","description":"The pending-consent response: nothing exists until the human clicks."},"EnrollmentState":{"type":"string","enum":["not_published","published_waiting","published_silent","receiving"],"title":"EnrollmentState","description":"Whether a monitored domain's owner has actually published our rua= address.\n\n`total_messages == 0` alone conflates three very different situations; this\nenum separates them so the dashboard can stop promising reports that can\nnever arrive (design §3.2):\n\n- `NOT_PUBLISHED`: our address is absent from the live rua= list.\n- `PUBLISHED_WAITING`: present, no reports yet, < `silent_hours` since first seen.\n- `PUBLISHED_SILENT`: present, no reports yet, ≥ `silent_hours` since first seen.\n- `RECEIVING`: reports arrived in the window (decided by volume, so it can\n  never contradict the readiness verdict derived from the same read)."},"EnrollmentStatusOut":{"properties":{"state":{"$ref":"#/components/schemas/EnrollmentState"},"published_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Published At"},"checked_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Checked At"}},"type":"object","required":["state","published_at","checked_at"],"title":"EnrollmentStatusOut","description":"The four-state RUA enrollment verdict (design §3.4).\n\nNamed `EnrollmentStatusOut` because `EnrollmentOut` is already the enrollment\nroute's model (`rua/enroll.py`). Carries **no record field by construction**,\nso an LLM-shaped record string can never reach this surface (the invariant)."},"EvidenceOut":{"properties":{"trusted_reports":{"type":"integer","title":"Trusted Reports"},"untrusted_reports":{"type":"integer","title":"Untrusted Reports"},"reporters":{"items":{"type":"string"},"type":"array","title":"Reporters"},"dominant_report_share":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Dominant Report Share"},"days_with_trusted_data":{"type":"integer","title":"Days With Trusted Data"},"concentrated":{"type":"boolean","title":"Concentrated"}},"type":"object","required":["trusted_reports","untrusted_reports","reporters","dominant_report_share","days_with_trusted_data","concentrated"],"title":"EvidenceOut","description":"What the verdict was computed from (D111) — counts and names, no prose.\n\nAdditive: every readiness surface (dashboard, `GET /v1/readiness`, the\n`get_readiness` MCP tool) serializes through `serialize_readiness`, so this\nobject appears on all three at once and can never differ between them.\n`concentrated` is a flag, not a gate — one reporter supplying nearly all the\nevidence is worth saying, and nothing is withheld for it."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"NextSteps":{"properties":{"summary":{"type":"string","title":"Summary"},"report_url":{"type":"string","title":"Report Url"},"monitor_url":{"type":"string","title":"Monitor Url"}},"type":"object","required":["summary","report_url","monitor_url"],"title":"NextSteps","description":"The hand-off block's wire shape (documentation only — see `build_next_steps`)."},"ProgressOut":{"properties":{"progress_pct":{"type":"number","title":"Progress Pct"},"step_base_pct":{"type":"integer","title":"Step Base Pct"},"evidence_fraction":{"type":"number","title":"Evidence Fraction"},"days_with_data":{"type":"integer","title":"Days With Data"},"worst_source_aligned_pct":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Worst Source Aligned Pct"},"unknown_volume_pct":{"type":"number","title":"Unknown Volume Pct"},"stage":{"type":"string","title":"Stage"}},"type":"object","required":["progress_pct","step_base_pct","evidence_fraction","days_with_data","worst_source_aligned_pct","unknown_volume_pct","stage"],"title":"ProgressOut","description":"The serialized D48 progress bar — the dashboard renders, never recomputes."},"ReadinessOut":{"properties":{"ready":{"type":"boolean","title":"Ready"},"current_step":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Current Step"},"next_step":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Step"},"next_record":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Record"},"blockers":{"items":{"$ref":"#/components/schemas/BlockerOut"},"type":"array","title":"Blockers"},"window_days":{"type":"integer","title":"Window Days"},"total_messages":{"type":"integer","title":"Total Messages"},"rejected_unknown":{"anyOf":[{"$ref":"#/components/schemas/RejectedUnknownOut"},{"type":"null"}]},"progress":{"$ref":"#/components/schemas/ProgressOut"},"enrollment":{"$ref":"#/components/schemas/EnrollmentStatusOut"},"evidence":{"$ref":"#/components/schemas/EvidenceOut"}},"type":"object","required":["ready","current_step","next_step","next_record","blockers","window_days","total_messages","progress","enrollment","evidence"],"title":"ReadinessOut","description":"The serialized `ReadinessVerdict` — `next_record` only when ready."},"RejectedUnknownOut":{"properties":{"sources":{"type":"integer","title":"Sources"},"messages":{"type":"integer","title":"Messages"}},"type":"object","required":["sources","messages"],"title":"RejectedUnknownOut","description":"D113 — unaligned unknown mail the published `p=reject` already rejects.\n\nPresent only at the terminal rung, where the unknown-sources blocker is not\nemitted: the same D50 aggregate, read as the policy's receipt rather than as\na reason to withhold a step that does not exist."},"ScanIn":{"properties":{"domain":{"type":"string","title":"Domain"}},"type":"object","required":["domain"],"title":"ScanIn","description":"The `POST /v1/scan` body — a bare domain (no Turnstile: agents can't solve one)."},"ScanReportOut":{"properties":{"domain":{"type":"string","title":"Domain"},"checks":{"items":{"$ref":"#/components/schemas/CheckResult"},"type":"array","title":"Checks"},"scanned_at":{"type":"string","format":"date-time","title":"Scanned At"},"explanations_pending":{"type":"boolean","title":"Explanations Pending","default":false},"dns_provider_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Dns Provider Key"},"not_registered":{"type":"boolean","title":"Not Registered","default":false},"next_steps":{"anyOf":[{"$ref":"#/components/schemas/NextSteps"},{"type":"null"}]},"indexable":{"type":"boolean","title":"Indexable","description":"Thin-page shield: worth indexing only if mail-capable AND substantive.\n\nMail-capable = any of mx/spf/dmarc observed a record; substantive = at\nleast `_MIN_RESOLVED_CHECKS` checks resolved (non-TEMPERROR). NXDOMAIN,\nparked, and garbage domains fail the first gate; half-broken scans fail\nthe second. Non-indexable reports stay fully viewable — they are only\nnoindex + sitemap-excluded. Serializes into `report_json`; older\npersisted JSON without the key still validates (computed on read).","readOnly":true},"dns_provider":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Dns Provider","description":"The serialized assist-only NS provider, looked up by key at read time.\n\nMirrors the domain-row `dns_provider` object: the key is stored, the full\nobject (display name, console URL, guide anchor, collision note) is\nresolved from the `DNS_PROVIDERS` table on serialize, so a URL/copy fix\nnever needs a re-scan. A key that no longer exists in the table (or None)\nserializes as None — the report page then omits the provider mention.","readOnly":true}},"type":"object","required":["domain","checks","indexable","dns_provider"],"title":"ScanReportOut","description":"What a scan endpoint actually returns: the report plus the A8 hand-off.\n\nDeclared as a response model rather than by widening `ScanReport` itself:\n`ScanReport` round-trips through the persisted `report_json`, and a field\nthere would be frozen into stored rows. This subclass is never handed to\n`save_report`, so the non-persistence property is untouched while\n`/api/v1/openapi.json` documents the real response shape — which the agent\nchannel (`/llms-full.txt`, U7's api-catalog) depends on being accurate."},"SignupUrlIn":{"properties":{"domain":{"type":"string","title":"Domain"}},"type":"object","required":["domain"],"title":"SignupUrlIn","description":"The `POST /v1/signup-url` body — a bare domain, same shape as `ScanIn`."},"SignupUrlOut":{"properties":{"signup_url":{"type":"string","title":"Signup Url"},"report_url":{"type":"string","title":"Report Url"},"message":{"type":"string","title":"Message"},"why_monitoring":{"type":"string","title":"Why Monitoring"}},"type":"object","required":["signup_url","report_url","message","why_monitoring"],"title":"SignupUrlOut","description":"The `POST /v1/signup-url` response — `build_signup_url`'s dict, key for key.\n\nAll four fields are relayed VERBATIM and a caller must never rebuild any of\nthem: the U5 verbatim-field rule is the whole point of returning both URLs\n(`mcp_server/signup.py`'s module docstring), `message` is fixed copy with\nexactly one substitution — a hand-off a human reads, not prose to paraphrase —\nand `why_monitoring` is fixed copy with none.\n\nMirrors `mcp_server/signup.py::build_signup_url` so the REST body is byte-equal\nto the `start_monitoring_signup` MCP tool result by construction."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"WatchCreated":{"properties":{"watch_id":{"type":"string","title":"Watch Id"},"kind":{"type":"string","title":"Kind"},"target":{"type":"string","title":"Target"},"record_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Record Type"},"expected_value":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expected Value"},"state":{"type":"string","title":"State"},"cadence_seconds":{"type":"integer","title":"Cadence Seconds"},"window_hours":{"type":"number","title":"Window Hours"},"price_usd":{"type":"string","title":"Price Usd"},"created_at":{"type":"string","title":"Created At"},"expires_at":{"type":"string","title":"Expires At"},"results_expire_at":{"type":"string","title":"Results Expire At"},"poll_url":{"type":"string","title":"Poll Url"}},"type":"object","required":["watch_id","kind","target","record_type","expected_value","state","cadence_seconds","window_hours","price_usd","created_at","expires_at","results_expire_at","poll_url"],"title":"WatchCreated","description":"The minted handle plus the terms it was sold on.\n\nDeterministic throughout: an echo of what the buyer asked for, the price that\nwas settled, and where to poll. No prose, no record, no prescription — the\nD94 invariant carries onto every watch surface."},"WatchPingIn":{"properties":{"url":{"type":"string","maxLength":2000,"title":"Url","description":"The URL to check. It is fetched over the shared probe fleet and stored for the life of the watch, so it must not carry a secret — no token, key or signature in the path or query."},"cadence_seconds":{"type":"integer","title":"Cadence Seconds","default":300},"window_hours":{"type":"number","maximum":24.0,"minimum":1.0,"title":"Window Hours","default":24}},"type":"object","required":["url"],"title":"WatchPingIn","description":"The ping watch's terms. The URL itself is validated in `watch/targets.py`.\n\n`cadence_seconds` is bounded to the two cadences we sell at the MODEL, so an\nunsold cadence is refused during parse rather than silently priced as the\ncheap one. The HTTP method is deliberately NOT a buyer knob: the probe fleet\nissues a `HEAD`, which is the smallest request that answers \"does this URL\nstill respond\" and the least useful thing to aim at a third party."},"WatchPropagationIn":{"properties":{"name":{"type":"string","maxLength":1012,"title":"Name"},"record_type":{"type":"string","title":"Record Type","default":"A"},"expected_value":{"anyOf":[{"type":"string","maxLength":1024},{"type":"null"}],"title":"Expected Value","description":"The value every vantage point must agree on for the watch to report `met`. Without it, `met` means the grid agreed on an answer set DIFFERENT from the first one recorded — a name whose answers legitimately rotate (an ELB/ALB or GSLB hostname) meets on the rotation, not on your change; pass the value you are waiting for."},"window_hours":{"type":"number","maximum":24.0,"minimum":1.0,"title":"Window Hours","default":24}},"type":"object","required":["name"],"title":"WatchPropagationIn","description":"The propagation watch's terms. Validation is `normalize_probe_name`'s.\n\n`window_hours` is bounded at the MODEL so an out-of-range window is refused\nduring parse — the metered-422 handler covers that leg, and a bound checked\nlater would price a window we do not sell. The bound is the one the surface's\npublished `input_schema` advertises (1 to 24), not a wider one: a machine\ncontract that under-states what it accepts is drift, and a sub-hour window\nwould pro-rate toward a `$0` offer on an always-paid surface."},"WatchStatus":{"properties":{"kind":{"type":"string","title":"Kind"},"target":{"type":"string","title":"Target"},"state":{"type":"string","title":"State"},"params":{"additionalProperties":true,"type":"object","title":"Params"},"created_at":{"type":"string","title":"Created At"},"expires_at":{"type":"string","title":"Expires At"},"results_expire_at":{"type":"string","title":"Results Expire At"},"last_checked_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Last Checked At"},"checks":{"type":"integer","title":"Checks"},"verdict":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Verdict"},"observations":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Observations"},"next_before":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Before"}},"type":"object","required":["kind","target","state","params","created_at","expires_at","results_expire_at","last_checked_at","checks","verdict","observations"],"title":"WatchStatus","description":"A watch's state and everything observed so far.\n\nDeterministic throughout, and a **relay**: `verdict` is the verdict the last\nobservation was recorded with, never re-derived here — the runner writes it\nfrom D94's own `derive_verdict`, so the poll surface cannot drift from the\non-demand check's rules (a faulted probe is an unreached vantage, never a\nnegative reading, and fewer than three contributing vantage points degrade\nthe verdict to `unknown`).\n\n`observations` are relayed verbatim as the runner recorded them. No prose, no\ncomposed record, no prescription-shaped field at any depth — the D94\ninvariant carries onto every watch surface."}},"securitySchemes":{"apiKey":{"type":"http","scheme":"bearer","description":"A DNS Doctor API token: `Authorization: Bearer dnsd_…`."}}},"servers":[{"url":"https://dnsdoctor.dev/api/v1"}],"x-discovery":{"ownershipProofs":["0xeb49cc7d9ac04add7ee0fbe51bc806b684ebdb6bfcdc8c2d7aa7eebc02032b93403764b0986d036ed68ad7e5914edc5403209f19806f4a87e15b1a5bdb73e9ab1b"]}}