cloud/envoy/dns_record.rs
1//! `dns.*` verb signatures — initial catalog (R409-T6).
2//!
3//! Four verbs that cover the DNS plane with Cloudflare as the exemplar
4//! tier-S provider (W144 §"dns.* — name resolution"):
5//!
6//! - `dns.record.upsert` — create or update a DNS record in a named zone
7//! - `dns.record.list` — read the records in a zone (R859-F1)
8//! - `dns.record.delete` — remove matching records from a zone
9//! - `dns.zone.list` — enumerate accessible zones
10//!
11//! `dns.record.list` landed with the first production consumer of this
12//! catalog — [`crate::reconciler::ensure_passway_apex`], which reconciles a
13//! `front_door = "passway"` apex from declared intent. A reconciler cannot be
14//! idempotent without reading current state first, and until R859-F1 there was
15//! no read verb at all: `dns.zone.list` enumerates zones, not records.
16//!
17//! ## Multi-valued RRsets (R859-F1)
18//!
19//! A round-robin apex carries several A records under one name, so
20//! `(name, type)` is **not** a unique key there. Two optional fields exist for
21//! exactly that shape and default to the pre-R859 behaviour:
22//!
23//! - [`DnsRecordUpsertInput::match_content`] — match the record to update by
24//! `(name, type, content)` instead of `(name, type)`. Without it, upserting
25//! a second A record at an apex that already has one *rewrites the first*
26//! rather than adding a sibling, silently collapsing the round-robin to one
27//! origin.
28//! - [`DnsRecordDeleteInput::content`] — delete only the records carrying that
29//! exact value, so pruning one withdrawn origin does not take its live
30//! siblings with it.
31//!
32//! Zone resolution is by apex name (e.g. `"yah.dev"`), not by provider-issued
33//! zone ID — the adapter owns the name→id lookup so callers stay
34//! provider-agnostic. The `type` field follows the RFC 1035 convention
35//! (uppercase strings: `"A"`, `"CNAME"`, `"TXT"`, etc.).
36//!
37//! @yah:ticket(R859-F1, "Domain reconciler arm for front_door = \"passway\": render A records from the ingress plan via dns.* verbs, retire cf-apex-mode.sh as the flip mechanism")
38//! @yah:status(review)
39//! @yah:at(2026-09-08T19:00:30Z)
40//! @yah:assignee(agent:bundle-anthropic-ashguard)
41//! @yah:parent(R859)
42//! @yah:next("domain.rs header says it plainly: front_door = \"passway\" 'has no reconciler here yet'. Build the arm: domain manifest + IngressPlan.front_doors → the set of public IPs of machines carrying that edge → dns.record.upsert (DNS-only, proxied=false) through the provider-agnostic dns.* verbs. Idempotent, list-first, like ensure_r2_custom_domain.")
43//! @yah:next("This dissolves the two-source front_door flip: the field in domains/*.toml becomes the single source and the reconciler renders it, so a flip is one line + apply instead of cf-apex-mode.sh + a manual TOML edit kept honest only by the publish beacon after the fact (it cost 19 days once, R330-B36, and 4 more, R703-B4).")
44//! @yah:next("Growing the public fleet organically falls out: adding a machine with the public-ip taint to an edge's machines list adds its A record on the next apply; removing it withdraws.")
45//! @yah:next("Keep cf-apex-mode.sh as break-glass (worker/orange flip under attack per W267 tier ladder) — retire it as the routine mechanism, don't delete it.")
46//! @yah:next("Tier: Wizard — new reconciler arm with provider seam, apply/validate wiring, and a live-DNS blast radius that needs careful idempotence tests.")
47//! @yah:handoff("LANDED. New passway arm in oss/yubaba/crates/cloud/src/reconciler/domain.rs, house pure-planner/IO-applier shape: public_origins() (collated front-door machine names -> machines carrying the public-ip taint -> connect.address, parsed as a public Ipv4Addr), plan_domain_passway() (front_door guard, sort+dedup by address, apex zone via parent_zone_name), diff_apex_records() (upsert/prune against the live A set), deploy_domain_passway() (list-first, skip-when-converged, upsert BEFORE prune, proxied=false always), and ensure_passway_apex() as the entry point yah cloud apply calls. Exported from reconciler/mod.rs. Apply wiring: app/yah/cli/src/cloud.rs:10944 `if dom.front_door != BucketDirect { Skipped }` became a 3-arm match — bucket-direct and passway both reconcile now, worker stays a Skip with a reason naming the Worker pass. domain.rs header sentence 'has no reconciler here yet' replaced.")
48//! @yah:handoff("DNS PRIMITIVES (decision 1, plus two additive fields that decision needed). New verb dns.record.list in envoy/dns_record.rs (zone + optional name + optional type -> records with id/name/type/content/ttl/proxied), registered in envoy.rs known_verb_descriptors + the ID assertion list (count 11 -> 12), implemented in provider/cloudflare_envoy.rs against GET /zones/{id}/dns_records via a new CloudflareClient::list_dns_records + pub DnsRecordDetail struct. TWO EXTRA FIELDS WERE UNAVOIDABLE and are the discovered work of this ticket: (a) DnsRecordUpsertInput.match_content (default false = pre-R859 behaviour) — CloudflareClient::upsert_dns_record matches on (name,type) and updates the FIRST match, so upserting a 2nd A record at an apex that already has one REWRITES the first and silently collapses the round-robin to one origin; new delegating CloudflareClient::upsert_dns_record_matching keys on (name,type,content) when set. (b) DnsRecordDeleteInput.content (Option, mirrors the existing record_type filter) — deleting 'the A records at yah.dev' to prune ONE withdrawn origin would take the live siblings with it; new delegating CloudflareClient::delete_dns_records_matching. Both old public signatures are unchanged and delegate, so nothing outside this ticket had to move.")
49//! @yah:handoff("TESTS + BASELINE. Baseline measured BEFORE editing at tree anchor 4bed91fe: `cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema` = lib 1104 passed / 0 failed / 4 ignored, tests/main.rs 3/0/1, pond_smoke 2/0, doc-tests 0/0/1. After: lib 1121 passed / 0 failed / 4 ignored (+17), other three targets unchanged. Also green: `cargo test -p yah --lib` 1442/0, `cargo test -p yah-agent-tools --lib` 1221/0, `cargo build --workspace` EXIT=0. New pure-planner tests in domain.rs's test module cover every case the dispatch asked for: converged set is a no-op; added machine yields exactly one added record and no prune; removed machine yields exactly one prune (type A) and leaves the survivor untouched; non-public address (100.64/10 tailnet, 10/8, 192.168/16, 127.0.0.1) and unparseable address are errors naming the machine; EMPTY front-door set is an error, not a wipe (`refusing to render an empty apex`). Plus: taint filter keeps only public-ip machines, plan sorts+dedups by address, front_door guard, a proxied record at a desired address is rewritten (not left orange, not pruned), full origin swap produces upsert+prune so the ordering contract holds.")
50//! @yah:handoff("ALSO TOUCHED (discovered work, all forced by the two new verb fields). app/yah/desktop/src/cloudflare.rs:210 constructs DnsRecordUpsertInput literally for tunnel CNAME sync — added `match_content: false` with a comment saying why a tunnel hostname is single-valued. crates/yah/agent-tools/src/envoy_tools.rs READ_VERB_IDS gained \"dns.record.list\": it is a pure read, and leaving it out would have had the agent tool surface classify it as a write and gate it behind write approval. scripts/cf-apex-mode.sh: header rewritten per decision 10 — every behaviour KEPT, but it is now labelled break-glass only, with the four jobs that remain its alone enumerated (worker rollback, orange flip under attack, `status` read, the CF-1052 R2-custom-domain diagnostic) and a note that a stale front_door will now actively UNDO an un-declared flip on the next apply rather than merely disagreeing with it. No manifest fields added (decisions 2/3/8), no new global lint in validate.rs (decision 4), R2 and worker arms untouched (decision 9), no schema regen needed (no config type moved; .yah/schema/ holds no envoy verb schemas).")
51//! @yah:assumes("Decision 5, recorded as instructed: the apex round-robins across EVERY node in collate_workspace_ingress(...).collation.front_doors whose provider is IngressProvider::Passway and that carries the public-ip taint. It does NOT filter by whether that node actually serves the specific domain's [[routes]]. This matches scripts/cf-apex-mode.sh's CF_ORIGIN_IP list semantics, and is correct while the camp has exactly one passway domain (yah.dev) and one passway edge. A second passway domain fronted by a different subset of nodes would get the union, not its own subset — that is the assumption to revisit first.")
52//! @yah:assumes("deploy_domain_passway's I/O path has NOT been exercised against a live Cloudflare account (no creds in this session) — same status the worker arm's doc comment records for itself. The decision logic is fully covered offline; treat the first real `yah cloud apply` against yah.dev as the acceptance test, and run it while the current apex A records are known so a wrong prune is visible immediately.")
53//! @yah:gotcha("Peer activity on the shared tree during this session, none of it mine and none needing action: (1) `cargo build --workspace` was transiently RED mid-session on yah-workload-spec (E0425 DURABILITY_ENGINE_ANNOTATION / DURABILITY_SUBJECTS_ANNOTATION undefined) from a peer's half-landed 166-line addition to oss/yah-base/crates/workload-spec/src/lib.rs — it went green on its own once they finished, and the final workspace build is EXIT=0. (2) app/yah/cli/src/cloud.rs carries two hunks that are not mine: the removal of the R856-F8 annotation block from the module header (an archive by another session), separate from my hunk at the domain apply loop. No collision — different regions of the file.")
54//! @yah:cleanup("Followup CANDIDATE, deliberately not filed (decision 8 said mention, do not file): the domain manifest has no `use = \"<id>\"` provider slot, so the apply site still hardcodes DOMAIN_CF_PROVIDER = \"cloudflare\" (app/yah/cli/src/cloud.rs:10939). The passway arm is already provider-agnostic ABOVE that line — every read and write goes through the dns.* envoy verbs — so giving domains a provider slot is now purely a config-plumbing change, and it is what would let a second DNS provider (the .yah/envoys/digitalocean sketch exists) serve an apex.")
55//! @yah:cleanup("parent_zone_name's two-label heuristic is REUSED here and is fine for this ticket (yah.dev is a two-label apex, so zone == name), but the limitation is unchanged and still noted at domain.rs's deploy_domain_worker caveat: a passway domain under an alias tier (net.yah.dev / com.yah.dev, which are their own CF zones) would resolve to the wrong zone. Not fixed here per the dispatch; the upgrade path is the longest-suffix match against dns.zone.list that parent_zone_name's own doc already names — and dns.zone.list is right there in the catalog now.")
56//! @yah:verify("cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema (lib 1121 passed / 0 failed vs baseline 1104/0 at anchor 4bed91fe)")
57//! @yah:verify("cargo build --workspace && cargo test -p yah --lib && cargo test -p yah-agent-tools --lib (all EXIT=0; 1442/0 and 1221/0)")
58//! @yah:handoff("PRUNE GATE (Leader's verification finding, fixed). ensure_passway_apex never inspected report.problems, and collate_workspace_ingress returns Ok while SKIPPING an edge whose declaration fails to plan — so a passway edge with a config typo drops its machine from front_doors, which from inside the plan is indistinguishable from an operator withdrawal, and the arm would have pruned that origin's live A record. A typo becoming a DNS withdrawal. Fix, per the decision handed down: fail-closed on withdrawal, fail-open on addition. DomainPasswayPlan gained `origins_complete: bool` (plan_domain_passway takes it as a third arg); ensure_passway_apex sets it from `report.problems.is_empty()` and warns once per problem via IngressProblem::message(). diff_apex_records routes surplus records into a new ApexRecordDiff.withheld_prune instead of prune when the flag is false — upserts are untouched, so growing the fleet still works through another service's broken declaration, and problems never fail the arm. deploy_domain_passway warns naming the withheld records BEFORE the converged early-return (the record stays live either way), and PasswayApexOutcome.withheld_prune carries them to the apply site, which prints `KEPT A <domain> -> <ip> (ingress collation reported problems — run yah cloud validate)`. The empty-set guard stays ahead of the gate: an empty origin set is an error whether or not the collation was clean, so it cannot degrade into a silent everything-withheld apply. deploy_domain_passway's doc comment now states the contract as a third invariant beside list-first and upsert-before-prune.")
59//! @yah:handoff("NUMBERS AFTER THE PRUNE GATE, superseding the counts in the TESTS + BASELINE entry above: yah-cloud lib 1124 passed / 0 failed / 4 ignored (was 1121 at the first pass, 1104 at the pre-edit baseline measured at anchor 4bed91fe); tests/main.rs 3/0/1, pond_smoke 2/0, doc-tests 0/0/1 all unchanged; `cargo build --workspace` EXIT=0. Three tests added for the gate: an incomplete collation upserts but withholds every prune (with a clean-collation control on the same inputs proving the gate is what changed the outcome); the empty-origin-set error still fires on an incomplete collation, so the louder failure stays ahead of the quieter one; and a withheld-prune-only diff reports is_converged (no write to make) while still carrying the withheld record. Leader's independent pass measured `cargo test -p yah --lib` at 1444/0 against my 1442/0 — peer drift on the shared tree, not a discrepancy in this work.")
60//! @yah:assumes("Both earlier assumes still hold as written; this one sharpens the first. The union-not-subset assumption above and the new prune gate come due at the SAME moment — the second passway edge. Today `report.problems` non-empty plus one passway edge collapses into the guarded empty-set error, so the gate is inert; with two edges it becomes the thing standing between a config typo and a live DNS withdrawal, and the union behaviour becomes the thing deciding which origins a second passway domain publishes. Whoever adds the second passway edge should re-read both together rather than either alone.")
61//! @yah:handoff("LEADER SIGN-OFF (independently verified, not taken on the courier's word). Two verification passes by a separate session re-ran the builds and traced the code. Pass 1 confirmed: upsert loop strictly precedes prune loop with provably disjoint sets; empty-origin-set is a hard error, not a wipe; the prune passes BOTH record_type Some(\"A\") and content Some(ip) through cloudflare_envoy.rs into the client-side filter, so MX/TXT/AAAA/CNAME are unreachable; the pre-change upsert_dns_record did take find_dns_record's first (name,type) match, match_content defaults false and only the passway arm sets true; proxied is always false with an existing orange record at a desired address rewritten rather than left; old public signatures delegate unchanged with the sole live caller untouched; dns.record.list registered in the catalog with the ID-assertion count 11 -> 12 and listed in READ_VERB_IDS so it is not gated as a write; the apply site is a real 3-arm match with Worker still a Skip; cf-apex-mode.sh has exactly one comment-only hunk. Pass 2 (after the prune-gate increment) re-confirmed all of it plus the gate itself.")
62//! @yah:handoff("Tree anchor at handoff: f086233d6b092de2f32cafad5e0010494078269c — the shared tree as I left it. Diff against it (`git diff f086233d6b092de2f32cafad5e0010494078269c..HEAD`) to see what landed under you, and quote this SHA rather than 'HEAD' in any revert/restore instruction.")
63//! @yah:handoff("DISCOVERED DEFECT, found by leader verification and fixed in-run rather than filed. The first-pass arm never inspected report.problems, and collate_workspace_ingress returns Ok while SKIPPING an edge whose declaration fails to plan (validate.rs:870-879) — so a config typo in a passway declaration would drop that machine from front_doors, the arm would read the absence as an operator withdrawal, and it would PRUNE a live A record. A typo becoming a DNS withdrawal is exactly the failure class this ticket exists to remove. Directed fix, landed: gate the PRUNE, not the upsert. DomainPasswayPlan.origins_complete is set from report.problems.is_empty() at its single non-test construction site (domain.rs:671, so the flag cannot be forged); when false, surplus records route into ApexRecordDiff.withheld_prune instead of prune. Upserts are computed above the branch and are unaffected, so growing the fleet keeps working under a broken unrelated declaration; nothing is ever withdrawn from an untrusted picture. Fail-closed on withdrawal, fail-open on addition. The arm does NOT fail on unrelated ingress problems — one broken service declaration must not block DNS apply. warn! naming each withheld record fires BEFORE the converged early-return, so a no-op apply still tells the operator, and PasswayApexOutcome carries them to app/yah/cli/src/cloud.rs:11002-11008 as a KEPT line printed outside the is_noop branch.")
64//! @yah:handoff("LIVE ACCEPTANCE RUN AND GREEN — the one item the previous handoff left open is closed, and it is now a repeatable in-tree check rather than a manual procedure. New `oss/yubaba/crates/cloud/tests/passway_apex_live.rs` (`#[ignore]`d, `mod`'d into tests/main.rs) runs the REAL planner over the checked-in `.yah/` tree, reads the REAL yah.dev zone through the REAL `dns.record.list` verb against the REAL Cloudflare account, and runs the REAL `diff_apex_records` over the pair. Result 2026-09-08: `zone yah.dev / name yah.dev; declared 45.32.194.254 (us-south-001), 51.81.85.145 (us-east-001); live 45.32.194.254, 51.81.85.145; diff ApexRecordDiff { upsert: [], prune: [], withheld_prune: [] }` — converged, so `yah cloud apply`'s domain pass has no DNS write to make. That is exactly what the old verify item's \"second apply writes nothing\" was meant to demonstrate, established WITHOUT performing the first apply. Independently corroborated by `dig +short yah.dev A` (same two addresses) and `yah cloud validate` (\"2 node front door(s) collate cleanly\", so origins_complete = true and the prune gate is not masking anything).")
65//! @yah:handoff("HOW THE CHECK IS SAFE TO LEAVE RUNNABLE — two small extractions in domain.rs, no behaviour change to any existing path. (1) `ensure_passway_apex` split into `plan_passway_apex(workspace_root, domain) -> Result<DomainPasswayPlan>` (pure: collate + taint/address resolve + plan, no network, no credential, no write path) plus the two-line applier that hands that plan to `deploy_domain_passway`; the `report.problems` prune-gate doc moved down onto the planner, where the code now lives. (2) `deploy_domain_passway`'s credential+list preamble split into private `passway_envoy()` / `read_live_apex()` and a public read-only `list_live_apex_records(workspace_root, provider_id, plan)`. The test calls ONLY those two public functions, so it physically cannot write — `dns.record.upsert` / `dns.record.delete` live in the half it does not touch. That is the point: a failure is a report, never a change, which is what lets the acceptance check live in-tree instead of in a handoff as a thing someone should do by hand one day. Both new symbols exported from reconciler/mod.rs. The apply path is unchanged: one credential resolution, same list-then-diff-then-write order.")
66//! @yah:handoff("DISCOVERED WORK: the domain manifest was still teaching the retired mechanism. `.yah/domains/yah-dev.toml`'s \"two shapes, and one command between them\" block told the next reader to flip the apex with `CF_ORIGIN_IP=51.81.85.145,45.32.194.254 scripts/cf-apex-mode.sh grey --apply` — the exact two-source flip this ticket exists to dissolve, sitting in the file whose `front_door` field is now the single source. Comment-only edit (no key changed, `yah cloud validate` re-run green): the passway arrow is now \"set `front_door = \\\"passway\\\"` below and `yah cloud apply`\", with a paragraph saying R859-F1 made this field the mechanism rather than the record of one, that the CF_ORIGIN_IP list is no longer typed by hand anywhere, and that using the script to flip without fixing this line no longer merely disagrees with the manifest — the next apply actively UNDOES it. cf-apex-mode.sh's own header already carried the break-glass framing from the first pass; this is the other half of that sentence, on the file an operator actually opens to do a flip.")
67//! @yah:handoff("Tree anchor for this increment: af805057ba632298d3e7e53c0197a164ed413824 (HEAD when the work landed; all five edited files were uncommitted against it). Quote this SHA, not 'HEAD', in any revert instruction. Files: oss/yubaba/crates/cloud/src/reconciler/domain.rs, .../reconciler/mod.rs, .../tests/main.rs, NEW .../tests/passway_apex_live.rs, .yah/domains/yah-dev.toml (comment-only). dns_record.rs is modified only by the board annotation this claim wrote.")
68//! @yah:handoff("REVIEW-READY. The one item the previous handoff left open — live acceptance — is closed and green, and is now a repeatable in-tree check rather than a manual procedure someone would have to remember. Everything else in this ticket's prior handoff entries still stands as written and was re-verified green this session.")
69//! @yah:verify("LIVE ACCEPTANCE, RUN 2026-09-08, GREEN: `cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema --test main -- --ignored --nocapture passway_apex_live` = 1 passed / 0 failed. Real planner + real dns.record.list against the real Cloudflare account: declared {45.32.194.254 us-south-001, 51.81.85.145 us-east-001}, live {45.32.194.254, 51.81.85.145}, diff {upsert: [], prune: [], withheld_prune: []}. The zone already matches the declaration, so the domain pass of `yah cloud apply` writes nothing.")
70//! @yah:verify("Corroborated independently of the code under test: `dig +short yah.dev A` returns the same two addresses; `yah cloud validate` reports '2 node front door(s) collate cleanly' (origins_complete = true, so no prune was silently withheld); both front-door machines carry taints = [... \"public-ip\"] with connect.address = the public IPv4 (.yah/infra/machines/us-east-001.toml:103, us-south-001.toml:96).")
71//! @yah:verify("cargo test --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema = lib 1152 passed / 0 failed / 4 ignored; tests/main 3 passed / 0 failed / 2 ignored (was 1 ignored — the new live check is the second); pond_smoke 2/0; doc-tests 0/0/1.")
72//! @yah:verify("cargo build --workspace EXIT=0 (twice, 4m21s and 7m07s — the camp's skew guard flagged concurrent peer edits to unrelated yubaba files on the first, hence the re-run). cargo clippy --manifest-path oss/yubaba/crates/cloud/Cargo.toml --features json-schema --all-targets: zero errors, and no new warning at any line this session touched.")
73//! @yah:verify("yah cloud validate re-run green AFTER the .yah/domains/yah-dev.toml comment edit, confirming the manifest still parses and cross-resolves.")
74//! @yah:assumes("The live check proves the DECISION path and the READ path against real Cloudflare; the WRITE path (dns.record.upsert / dns.record.delete) is still unexercised live, and cannot be exercised without deliberately diverging the apex. Its first real exercise will be the next actual origin change — add or remove a public-ip machine from the passway edge and the next apply is the write-path acceptance test. Watch it with `scripts/cf-apex-mode.sh status` open.")
75//! @yah:assumes("I did NOT run a real `yah cloud apply`. Deliberate, and the reason is scope rather than caution: the domain pass is proven a no-op by the check above, while `yah cloud apply` also publishes to R2 and reconciles every service — a far wider outward-facing action than this ticket, and running it would prove nothing about the apex that the check has not already proven.")
76//! @yah:gotcha("DEFECT IN THIS TICKET'S OWN SHIPPED CHANGE, found 2026-09-08 by auditing the NOISETABLE camp and fixed here before sign-off. Making the apply site reconcile `passway` (it used to `Skip` every non-bucket-direct door) turns a legitimate state into a fatal apply for any camp that declares `front_door = \\\"passway\\\"` before the passway edge exists. noisetable.com is exactly that: it declares passway, its only ingress edge is a `cloudflare-tunnel` on api.noisetable.com, and its apex is deliberately NXDOMAIN pending a node. The chain, all read not inferred: ensure_passway_apex filters front_doors to IngressProvider::Passway -> empty -> plan_domain_passway's empty-apex guard bails (domain.rs) -> the apply site pushes DomainOutcome::Failed and then `bail!(\\\"apply stopped at first domain failure\\\")` unless --continue-on-error (app/yah/cli/src/cloud.rs:11055-11063). One not-yet-built door would have taken down the publish chain for every service and every other domain in that camp — and noisetable had deliberately engineered `front_door = \\\"passway\\\"` precisely to KEEP that chain green (its manifest says so).")
77//! @yah:handoff("FIX FOR THAT DEFECT (refines the dispatch's \\\"EMPTY front-door set is an error\\\" decision rather than reversing it — the decision's PURPOSE was never wipe an apex, and skipping serves that purpose equally, writing nothing at all). The discriminator is drawn at the COLLATION, not at the resolved-address set, because an absent edge and an edge that resolves to nothing want opposite treatment. plan_passway_apex now returns Result<Option<DomainPasswayPlan>>, None = no passway edge collates at all. plan_domain_passway is UNCHANGED and still errors on an empty origin set, so a declared front-door machine that lacks the public-ip taint or carries a private address is still a hard error — an intended door resolving to nothing is a misconfiguration, not an absence. ensure_passway_apex returns Result<Option<PasswayApexOutcome>> and, on None, does ONE more thing before skipping: it reads the live A records at the apex. Empty -> the door was never stood up, skip quietly. Non-empty -> an edge that WAS fronting this apex has vanished from the declaration and those records now point at whatever used to serve; that is a hard error naming every live address. The read is free in practice — this arm only runs inside the domain pass, which already resolved a Cloudflare provider and account_id before entering the loop. read_live_apex was retargeted from (&plan) to (&zone, &name) so the no-plan branch can use it. Apply site prints `no passway ingress edge declared yet and the apex is empty — nothing rendered`.")
78//! @yah:verify("AFTER THE NOISETABLE FIX, superseding the counts above: yah-cloud lib 1138 passed / 0 failed / 4 ignored (was 1136; +2 tests). Both new tests pin the discriminator from opposite sides — a_passway_domain_with_no_ingress_edge_plans_to_none_rather_than_erroring builds a tempdir workspace with a services tree and no edge and asserts Ok(None) (empirical, not reasoned: this is the noisetable shape), and a_declared_front_door_that_resolves_to_no_public_address_still_errors declares us-east-001 as a front door WITHOUT the public-ip taint and asserts the empty-apex error still fires, so the fix cannot be read as \\\"empty is always fine\\\". tests/main 3/0/2, pond_smoke 2/0, doc-tests 0/0/1 unchanged. `cargo build --workspace` EXIT=0. LIVE ACCEPTANCE RE-RUN after the signature change and still green — same converged diff on yah.dev, confirming a camp that HAS its door is unaffected by the skip path.")
79
80use serde::{Deserialize, Serialize};
81
82use super::{InternalVerb, VerbCategory};
83
84// ── dns.record.upsert ─────────────────────────────────────────────────────
85
86/// Marker type for the `dns.record.upsert` verb.
87pub struct DnsRecordUpsert;
88
89/// Request body for `dns.record.upsert`.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
92pub struct DnsRecordUpsertInput {
93 /// Zone apex name, e.g. `"yah.dev"`. The adapter resolves it to a
94 /// provider-issued zone ID.
95 pub zone: String,
96 /// Fully-qualified record name, e.g. `"yubaba.yah.dev"`. Apex records
97 /// may also be passed as `"@"` — adapters normalise as needed.
98 pub name: String,
99 /// DNS record type (`"A"`, `"AAAA"`, `"CNAME"`, `"TXT"`, `"MX"`, …).
100 #[serde(rename = "type")]
101 pub record_type: String,
102 /// Record value: for CNAME the target hostname; for A/AAAA the IP; for
103 /// TXT the verbatim string content.
104 pub content: String,
105 /// TTL in seconds. `1` means "automatic" (effective TTL chosen by the
106 /// provider). Defaults to `1`.
107 #[serde(default = "ttl_auto")]
108 pub ttl: u32,
109 /// Route through Cloudflare's reverse proxy (orange-cloud). Only
110 /// meaningful on Cloudflare for A/AAAA/CNAME records; adapters for
111 /// other providers should ignore this field. Defaults to `false`.
112 #[serde(default)]
113 pub proxied: bool,
114 /// Match the record to replace by `(name, type, content)` rather than
115 /// `(name, type)` — R859-F1.
116 ///
117 /// `false` (the default, and the only shape before R859) is right for a
118 /// single-valued name: "whatever CNAME is at `cdn.yah.dev`, make it point
119 /// here". `true` is required for a **multi-valued RRset** such as a
120 /// round-robin apex, where several A records legitimately share
121 /// name+type: it turns the verb into ensure-this-exact-record-exists, so
122 /// building a 2-origin apex is two upserts rather than one upsert that
123 /// overwrites the other origin.
124 #[serde(default)]
125 pub match_content: bool,
126}
127
128fn ttl_auto() -> u32 {
129 1
130}
131
132/// Response body for `dns.record.upsert`.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
135pub struct DnsRecordUpsertOutput {
136 /// Provider-issued record ID. Stable for the lifetime of the record;
137 /// can be used in `dns.record.delete` to target a specific record by ID
138 /// instead of name+type once that verb shape grows an `id` field.
139 pub id: String,
140}
141
142impl InternalVerb for DnsRecordUpsert {
143 type Input = DnsRecordUpsertInput;
144 type Output = DnsRecordUpsertOutput;
145 const ID: &'static str = "dns.record.upsert";
146 const CATEGORY: VerbCategory = VerbCategory::Dns;
147}
148
149// ── dns.record.delete ─────────────────────────────────────────────────────
150
151/// Marker type for the `dns.record.delete` verb.
152pub struct DnsRecordDelete;
153
154/// Request body for `dns.record.delete`.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
157pub struct DnsRecordDeleteInput {
158 /// Zone apex name, e.g. `"yah.dev"`.
159 pub zone: String,
160 /// Record name to delete, e.g. `"yubaba.yah.dev"`.
161 pub name: String,
162 /// Filter by record type. When absent, all records matching `name` are
163 /// deleted regardless of type. Pass `"CNAME"` to delete only CNAME
164 /// records for the name, for example.
165 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
166 pub record_type: Option<String>,
167 /// Filter by exact record value — R859-F1. When absent, every record
168 /// matching `name` (and `record_type`) is deleted.
169 ///
170 /// Present so a caller pruning one member of a multi-valued RRset can name
171 /// it: deleting "the A records at `yah.dev`" would take the surviving
172 /// origins down with the withdrawn one.
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub content: Option<String>,
175}
176
177/// Response body for `dns.record.delete`.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
180pub struct DnsRecordDeleteOutput {
181 /// Count of records actually deleted. `0` is not an error — the record
182 /// may already have been absent (idempotent).
183 pub deleted: u32,
184}
185
186impl InternalVerb for DnsRecordDelete {
187 type Input = DnsRecordDeleteInput;
188 type Output = DnsRecordDeleteOutput;
189 const ID: &'static str = "dns.record.delete";
190 const CATEGORY: VerbCategory = VerbCategory::Dns;
191}
192
193// ── dns.record.list ───────────────────────────────────────────────────────
194
195/// Marker type for the `dns.record.list` verb (R859-F1).
196pub struct DnsRecordList;
197
198/// Request body for `dns.record.list`.
199#[derive(Debug, Clone, Default, Serialize, Deserialize)]
200#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
201pub struct DnsRecordListInput {
202 /// Zone apex name, e.g. `"yah.dev"`.
203 pub zone: String,
204 /// Restrict to records with this exact name, e.g. `"yah.dev"` for the
205 /// apex. When absent, every record in the zone is returned.
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub name: Option<String>,
208 /// Restrict to one record type (`"A"`, `"CNAME"`, …). When absent, every
209 /// type is returned — which is why a caller that only owns the A records
210 /// at a name must pass `"A"`: MX and TXT live at the apex too.
211 #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
212 pub record_type: Option<String>,
213}
214
215/// One live DNS record.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
218pub struct DnsRecordEntry {
219 /// Provider-issued record ID — the same identifier
220 /// [`DnsRecordUpsertOutput::id`] returns.
221 pub id: String,
222 /// Fully-qualified record name, e.g. `"yah.dev"`.
223 pub name: String,
224 /// DNS record type (`"A"`, `"CNAME"`, `"TXT"`, …).
225 #[serde(rename = "type")]
226 pub record_type: String,
227 /// Record value: the IP for A/AAAA, the target hostname for CNAME, …
228 pub content: String,
229 /// TTL in seconds; `1` means the provider chooses.
230 #[serde(default = "ttl_auto")]
231 pub ttl: u32,
232 /// Whether the provider proxies this record (Cloudflare orange-cloud).
233 /// Always `false` from providers with no such concept.
234 #[serde(default)]
235 pub proxied: bool,
236}
237
238/// Response body for `dns.record.list`.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
241pub struct DnsRecordListOutput {
242 /// Matching records, in provider order. An empty list is not an error.
243 pub records: Vec<DnsRecordEntry>,
244}
245
246impl InternalVerb for DnsRecordList {
247 type Input = DnsRecordListInput;
248 type Output = DnsRecordListOutput;
249 const ID: &'static str = "dns.record.list";
250 const CATEGORY: VerbCategory = VerbCategory::Dns;
251}
252
253// ── dns.zone.list ─────────────────────────────────────────────────────────
254
255/// Marker type for the `dns.zone.list` verb.
256pub struct DnsZoneList;
257
258/// Request body for `dns.zone.list`. Empty — zone listing requires no
259/// parameters beyond the adapter's credential scope.
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
261#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
262pub struct DnsZoneListInput {}
263
264/// One zone entry in the `dns.zone.list` response.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
267pub struct DnsZoneEntry {
268 /// Provider-issued zone ID. Opaque; stable within a provider.
269 pub id: String,
270 /// Zone apex name, e.g. `"yah.dev"`.
271 pub name: String,
272}
273
274/// Response body for `dns.zone.list`.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
277pub struct DnsZoneListOutput {
278 pub zones: Vec<DnsZoneEntry>,
279}
280
281impl InternalVerb for DnsZoneList {
282 type Input = DnsZoneListInput;
283 type Output = DnsZoneListOutput;
284 const ID: &'static str = "dns.zone.list";
285 const CATEGORY: VerbCategory = VerbCategory::Dns;
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn verb_ids_match_canonical_namespace() {
294 assert_eq!(DnsRecordUpsert::ID, "dns.record.upsert");
295 assert_eq!(DnsRecordList::ID, "dns.record.list");
296 assert_eq!(DnsRecordDelete::ID, "dns.record.delete");
297 assert_eq!(DnsZoneList::ID, "dns.zone.list");
298 for id in [
299 DnsRecordUpsert::ID,
300 DnsRecordList::ID,
301 DnsRecordDelete::ID,
302 DnsZoneList::ID,
303 ] {
304 assert!(id.starts_with("dns."), "{id}");
305 }
306 }
307
308 #[test]
309 fn verbs_are_under_dns_category() {
310 assert_eq!(DnsRecordUpsert::CATEGORY, VerbCategory::Dns);
311 assert_eq!(DnsRecordList::CATEGORY, VerbCategory::Dns);
312 assert_eq!(DnsRecordDelete::CATEGORY, VerbCategory::Dns);
313 assert_eq!(DnsZoneList::CATEGORY, VerbCategory::Dns);
314 }
315
316 #[test]
317 fn upsert_input_defaults_ttl_to_auto_and_proxied_false() {
318 let wire = r#"{"zone":"yah.dev","name":"yubaba.yah.dev","type":"CNAME","content":"t.cfargotunnel.com"}"#;
319 let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
320 assert_eq!(parsed.ttl, 1, "default TTL should be 1 (automatic)");
321 assert!(!parsed.proxied, "default proxied should be false");
322 assert!(
323 !parsed.match_content,
324 "default must stay match-by-(name,type) — R859-F1 added the field"
325 );
326 }
327
328 /// R859-F1: a round-robin apex needs upserts keyed on content, otherwise
329 /// the second origin overwrites the first.
330 #[test]
331 fn upsert_input_accepts_match_content() {
332 let wire = r#"{"zone":"yah.dev","name":"yah.dev","type":"A","content":"51.81.85.145","match_content":true}"#;
333 let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
334 assert!(parsed.match_content);
335 }
336
337 /// R859-F1: pruning one withdrawn origin must not be expressible only as
338 /// "delete the A records at the apex".
339 #[test]
340 fn delete_input_content_filter_is_optional_and_omitted_when_absent() {
341 let with_content =
342 r#"{"zone":"yah.dev","name":"yah.dev","type":"A","content":"15.204.89.240"}"#;
343 let parsed: DnsRecordDeleteInput = serde_json::from_str(with_content).unwrap();
344 assert_eq!(parsed.content.as_deref(), Some("15.204.89.240"));
345
346 let bare = DnsRecordDeleteInput {
347 zone: "yah.dev".into(),
348 name: "yah.dev".into(),
349 record_type: Some("A".into()),
350 content: None,
351 };
352 let wire = serde_json::to_value(&bare).unwrap();
353 assert!(!wire.as_object().unwrap().contains_key("content"));
354 }
355
356 #[test]
357 fn record_list_input_omits_absent_filters() {
358 let input = DnsRecordListInput {
359 zone: "yah.dev".into(),
360 ..Default::default()
361 };
362 let wire = serde_json::to_value(&input).unwrap();
363 assert_eq!(wire, serde_json::json!({"zone": "yah.dev"}));
364 }
365
366 #[test]
367 fn record_list_output_round_trips_with_type_renamed() {
368 let out = DnsRecordListOutput {
369 records: vec![DnsRecordEntry {
370 id: "r1".into(),
371 name: "yah.dev".into(),
372 record_type: "A".into(),
373 content: "51.81.85.145".into(),
374 ttl: 1,
375 proxied: false,
376 }],
377 };
378 let wire = serde_json::to_value(&out).unwrap();
379 assert_eq!(wire["records"][0]["type"], "A");
380 assert!(wire["records"][0].get("record_type").is_none());
381 let back: DnsRecordListOutput = serde_json::from_value(wire).unwrap();
382 assert_eq!(back.records, out.records);
383 }
384
385 #[test]
386 fn upsert_input_type_renamed_in_wire() {
387 let wire = r#"{"zone":"yah.dev","name":"a.yah.dev","type":"A","content":"1.2.3.4","ttl":300,"proxied":true}"#;
388 let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
389 assert_eq!(parsed.record_type, "A");
390 assert_eq!(parsed.ttl, 300);
391 assert!(parsed.proxied);
392 // Verify the Rust field serializes back as "type".
393 let back = serde_json::to_value(&parsed).unwrap();
394 assert!(back.get("type").is_some(), "should serialize as 'type'");
395 assert!(
396 back.get("record_type").is_none(),
397 "should not serialize as 'record_type'"
398 );
399 }
400
401 #[test]
402 fn delete_input_type_optional() {
403 let with_type = r#"{"zone":"yah.dev","name":"old.yah.dev","type":"CNAME"}"#;
404 let parsed: DnsRecordDeleteInput = serde_json::from_str(with_type).unwrap();
405 assert_eq!(parsed.record_type.as_deref(), Some("CNAME"));
406
407 let no_type = r#"{"zone":"yah.dev","name":"old.yah.dev"}"#;
408 let parsed: DnsRecordDeleteInput = serde_json::from_str(no_type).unwrap();
409 assert!(parsed.record_type.is_none());
410 }
411
412 #[test]
413 fn delete_input_omits_type_when_absent() {
414 let input = DnsRecordDeleteInput {
415 zone: "z".into(),
416 name: "n".into(),
417 record_type: None,
418 content: None,
419 };
420 let wire = serde_json::to_value(&input).unwrap();
421 assert!(!wire.as_object().unwrap().contains_key("type"));
422 }
423
424 #[test]
425 fn delete_output_zero_is_not_an_error() {
426 let out = DnsRecordDeleteOutput { deleted: 0 };
427 let wire = serde_json::to_value(&out).unwrap();
428 assert_eq!(wire["deleted"], 0);
429 }
430
431 #[test]
432 fn zone_list_input_serializes_to_empty_object() {
433 let wire = serde_json::to_value(DnsZoneListInput::default()).unwrap();
434 assert_eq!(wire, serde_json::json!({}));
435 }
436
437 #[test]
438 fn zone_list_output_round_trips() {
439 let out = DnsZoneListOutput {
440 zones: vec![
441 DnsZoneEntry {
442 id: "z1".into(),
443 name: "yah.dev".into(),
444 },
445 DnsZoneEntry {
446 id: "z2".into(),
447 name: "noisetable.com".into(),
448 },
449 ],
450 };
451 let wire = serde_json::to_string(&out).unwrap();
452 let back: DnsZoneListOutput = serde_json::from_str(&wire).unwrap();
453 assert_eq!(back.zones.len(), 2);
454 assert_eq!(back.zones[0].name, "yah.dev");
455 }
456
457 #[cfg(feature = "json-schema")]
458 #[test]
459 fn verbs_emit_schemas_via_for_verb() {
460 use super::super::VerbDescriptor;
461
462 let upsert = VerbDescriptor::for_verb::<DnsRecordUpsert>();
463 assert_eq!(upsert.id, "dns.record.upsert");
464 assert!(upsert.input_schema.to_string().contains("content"));
465
466 let delete = VerbDescriptor::for_verb::<DnsRecordDelete>();
467 assert_eq!(delete.id, "dns.record.delete");
468 assert!(delete.output_schema.to_string().contains("deleted"));
469
470 let list = VerbDescriptor::for_verb::<DnsZoneList>();
471 assert_eq!(list.id, "dns.zone.list");
472 assert!(list.output_schema.to_string().contains("zones"));
473
474 let records = VerbDescriptor::for_verb::<DnsRecordList>();
475 assert_eq!(records.id, "dns.record.list");
476 assert!(records.output_schema.to_string().contains("records"));
477 }
478}