---
title: "Dmarc Upgrade"
method: POST
path: "/dmarc-upgrade"
tags: ["scan"]
---

# Dmarc Upgrade

`POST /dmarc-upgrade`

## Request

- Base URL: `https://dnsdoctor.dev/api/v1`
- URL: `https://dnsdoctor.dev/api/v1/dmarc-upgrade`
- Auth: none required

Return the server-gated DMARC enforcement record for `body.domain`.

The REST twin of the `build_dmarc_upgrade` MCP tool, running the SAME core
(`mcp_server/tools.py::build_dmarc_upgrade_from_domain`) rather than a second
implementation, so the two agent transports cannot drift: the record is
`build_upgrade_record` output byte for byte, and the ceiling is
`p=quarantine`, unlocked by a **server-side** alignment gate — a caller
cannot assert alignment to buy a stronger policy, and `p=reject` is not
reachable from any scan (D101: only the readiness engine's aggregate-report
evidence unlocks it).

Anonymous allowed. Scans fresh (see the core's docstring for why a stale
baseline is silently destructive here), which is why this path carries the
same `enforce_v1_rate_limit` and per-target scan budget the scan routes do.

`record: null` is an ANSWER, not an error: the domain does not exist, DMARC
could not be read, the published policy is already at least as strong as
this scan justifies, or the scan observed no alignment signal at all — the
D101 reporting-first refusal, whose `rationale` asks for `rua=` reporting
rather than offering a weaker record. Relay `rationale`; never compose a
record to fill it.

Error mapping — the transport-neutral core errors become HTTP:
`ToolInputError` → 422 with the core's own detail, `ToolTransientError` →
503 (retrying the identical call is the right response), and the pipeline's
per-target budget → the same 429 both scan routes answer with. Suppression is
refused here first, with the byte-identical 404 an unscanned domain gets, so
the core's own suppression branch (a `ToolInputError`, which would surface as
a 422) is never reached.

## Request body

- DmarcUpgradeIn: The `POST /v1/dmarc-upgrade` body — a bare domain, same shape as `ScanIn`.
  - `domain` string, required

## Response `200`

Successful Response

- DmarcUpgradeOut: The `POST /v1/dmarc-upgrade` response — the MCP tool result, key for key. Declared explicitly (rather than left as a bare dict) so `/openapi.json` documents the real shape, including the nullability that carries the contract: `record` is null on every honest refusal — the answer is then `rationale`, and a caller must NEVER compose a record to fill the gap. `policy` names the policy the RETURNED record sets, so it is null exactly when `record` is; the domain's observed policy travels in `current_policy` (also null when no DMARC record could be read). The field set mirrors `mcp_server/tools.py::build_dmarc_upgrade_tool` — the same core both surfaces run — so the REST body is byte-equal to the MCP tool result by construction. A key added there is added here.
  - `domain` string, required
  - `record` string, nullable, required
  - `policy` string, nullable, required
  - `current_policy` string, nullable, required
  - `alignment_ok` boolean, required
  - `rationale` string, required
  - `apply_note` string, required

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/dnsdoctor/apis/dns-doctor-api.md) · [All operations](https://skmtc.dev/dnsdoctor/apis/dns-doctor-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dnsdoctor/apis/dns-doctor-api/revisions/1d0114806e76?raw)
