yah-cloud 0.8.33

Declarative cloud substrate for yah-managed camps: .yah/cloud/ config schema, MachineProvider drivers (Hetzner + local containerd), cloud-init rendering, and the pond/mesofact reconcilers.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
//! `dns.*` verb signatures — initial catalog (R409-T6).
//!
//! Four verbs that cover the DNS plane with Cloudflare as the exemplar
//! tier-S provider (W144 §"dns.* — name resolution"):
//!
//! - `dns.record.upsert` — create or update a DNS record in a named zone
//! - `dns.record.list`   — read the records in a zone (R859-F1)
//! - `dns.record.delete` — remove matching records from a zone
//! - `dns.zone.list`    — enumerate accessible zones
//!
//! `dns.record.list` landed with the first production consumer of this
//! catalog — [`crate::reconciler::ensure_passway_apex`], which reconciles a
//! `front_door = "passway"` apex from declared intent. A reconciler cannot be
//! idempotent without reading current state first, and until R859-F1 there was
//! no read verb at all: `dns.zone.list` enumerates zones, not records.
//!
//! ## Multi-valued RRsets (R859-F1)
//!
//! A round-robin apex carries several A records under one name, so
//! `(name, type)` is **not** a unique key there. Two optional fields exist for
//! exactly that shape and default to the pre-R859 behaviour:
//!
//! - [`DnsRecordUpsertInput::match_content`] — match the record to update by
//!   `(name, type, content)` instead of `(name, type)`. Without it, upserting
//!   a second A record at an apex that already has one *rewrites the first*
//!   rather than adding a sibling, silently collapsing the round-robin to one
//!   origin.
//! - [`DnsRecordDeleteInput::content`] — delete only the records carrying that
//!   exact value, so pruning one withdrawn origin does not take its live
//!   siblings with it.
//!
//! Zone resolution is by apex name (e.g. `"yah.dev"`), not by provider-issued
//! zone ID — the adapter owns the name→id lookup so callers stay
//! provider-agnostic. The `type` field follows the RFC 1035 convention
//! (uppercase strings: `"A"`, `"CNAME"`, `"TXT"`, etc.).
//!
//! @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")
//! @yah:at(2026-09-05T09:31:37Z)
//! @yah:assignee(agent:bundle-anthropic-glimmerstone)
//! @yah:parent(R859)
//! @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.")
//! @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).")
//! @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.")
//! @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.")
//! @yah:next("Tier: Wizard — new reconciler arm with provider seam, apply/validate wiring, and a live-DNS blast radius that needs careful idempotence tests.")
//! @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.")
//! @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.")
//! @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.")
//! @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).")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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)")
//! @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)")
//! @yah:verify("LIVE ACCEPTANCE, not yet run and the one thing left: `yah cloud apply` against yah.dev with the current apex A records recorded first (scripts/cf-apex-mode.sh status), then a second apply to confirm it writes nothing the second time. The I/O path has no live-credential coverage in this session.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")
//! @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.")

use serde::{Deserialize, Serialize};

use super::{InternalVerb, VerbCategory};

// ── dns.record.upsert ─────────────────────────────────────────────────────

/// Marker type for the `dns.record.upsert` verb.
pub struct DnsRecordUpsert;

/// Request body for `dns.record.upsert`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordUpsertInput {
    /// Zone apex name, e.g. `"yah.dev"`. The adapter resolves it to a
    /// provider-issued zone ID.
    pub zone: String,
    /// Fully-qualified record name, e.g. `"yubaba.yah.dev"`. Apex records
    /// may also be passed as `"@"` — adapters normalise as needed.
    pub name: String,
    /// DNS record type (`"A"`, `"AAAA"`, `"CNAME"`, `"TXT"`, `"MX"`, …).
    #[serde(rename = "type")]
    pub record_type: String,
    /// Record value: for CNAME the target hostname; for A/AAAA the IP; for
    /// TXT the verbatim string content.
    pub content: String,
    /// TTL in seconds. `1` means "automatic" (effective TTL chosen by the
    /// provider). Defaults to `1`.
    #[serde(default = "ttl_auto")]
    pub ttl: u32,
    /// Route through Cloudflare's reverse proxy (orange-cloud). Only
    /// meaningful on Cloudflare for A/AAAA/CNAME records; adapters for
    /// other providers should ignore this field. Defaults to `false`.
    #[serde(default)]
    pub proxied: bool,
    /// Match the record to replace by `(name, type, content)` rather than
    /// `(name, type)` — R859-F1.
    ///
    /// `false` (the default, and the only shape before R859) is right for a
    /// single-valued name: "whatever CNAME is at `cdn.yah.dev`, make it point
    /// here". `true` is required for a **multi-valued RRset** such as a
    /// round-robin apex, where several A records legitimately share
    /// name+type: it turns the verb into ensure-this-exact-record-exists, so
    /// building a 2-origin apex is two upserts rather than one upsert that
    /// overwrites the other origin.
    #[serde(default)]
    pub match_content: bool,
}

fn ttl_auto() -> u32 {
    1
}

/// Response body for `dns.record.upsert`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordUpsertOutput {
    /// Provider-issued record ID. Stable for the lifetime of the record;
    /// can be used in `dns.record.delete` to target a specific record by ID
    /// instead of name+type once that verb shape grows an `id` field.
    pub id: String,
}

impl InternalVerb for DnsRecordUpsert {
    type Input = DnsRecordUpsertInput;
    type Output = DnsRecordUpsertOutput;
    const ID: &'static str = "dns.record.upsert";
    const CATEGORY: VerbCategory = VerbCategory::Dns;
}

// ── dns.record.delete ─────────────────────────────────────────────────────

/// Marker type for the `dns.record.delete` verb.
pub struct DnsRecordDelete;

/// Request body for `dns.record.delete`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordDeleteInput {
    /// Zone apex name, e.g. `"yah.dev"`.
    pub zone: String,
    /// Record name to delete, e.g. `"yubaba.yah.dev"`.
    pub name: String,
    /// Filter by record type. When absent, all records matching `name` are
    /// deleted regardless of type. Pass `"CNAME"` to delete only CNAME
    /// records for the name, for example.
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
    pub record_type: Option<String>,
    /// Filter by exact record value — R859-F1. When absent, every record
    /// matching `name` (and `record_type`) is deleted.
    ///
    /// Present so a caller pruning one member of a multi-valued RRset can name
    /// it: deleting "the A records at `yah.dev`" would take the surviving
    /// origins down with the withdrawn one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
}

/// Response body for `dns.record.delete`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordDeleteOutput {
    /// Count of records actually deleted. `0` is not an error — the record
    /// may already have been absent (idempotent).
    pub deleted: u32,
}

impl InternalVerb for DnsRecordDelete {
    type Input = DnsRecordDeleteInput;
    type Output = DnsRecordDeleteOutput;
    const ID: &'static str = "dns.record.delete";
    const CATEGORY: VerbCategory = VerbCategory::Dns;
}

// ── dns.record.list ───────────────────────────────────────────────────────

/// Marker type for the `dns.record.list` verb (R859-F1).
pub struct DnsRecordList;

/// Request body for `dns.record.list`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordListInput {
    /// Zone apex name, e.g. `"yah.dev"`.
    pub zone: String,
    /// Restrict to records with this exact name, e.g. `"yah.dev"` for the
    /// apex. When absent, every record in the zone is returned.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// Restrict to one record type (`"A"`, `"CNAME"`, …). When absent, every
    /// type is returned — which is why a caller that only owns the A records
    /// at a name must pass `"A"`: MX and TXT live at the apex too.
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "type")]
    pub record_type: Option<String>,
}

/// One live DNS record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordEntry {
    /// Provider-issued record ID — the same identifier
    /// [`DnsRecordUpsertOutput::id`] returns.
    pub id: String,
    /// Fully-qualified record name, e.g. `"yah.dev"`.
    pub name: String,
    /// DNS record type (`"A"`, `"CNAME"`, `"TXT"`, …).
    #[serde(rename = "type")]
    pub record_type: String,
    /// Record value: the IP for A/AAAA, the target hostname for CNAME, …
    pub content: String,
    /// TTL in seconds; `1` means the provider chooses.
    #[serde(default = "ttl_auto")]
    pub ttl: u32,
    /// Whether the provider proxies this record (Cloudflare orange-cloud).
    /// Always `false` from providers with no such concept.
    #[serde(default)]
    pub proxied: bool,
}

/// Response body for `dns.record.list`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsRecordListOutput {
    /// Matching records, in provider order. An empty list is not an error.
    pub records: Vec<DnsRecordEntry>,
}

impl InternalVerb for DnsRecordList {
    type Input = DnsRecordListInput;
    type Output = DnsRecordListOutput;
    const ID: &'static str = "dns.record.list";
    const CATEGORY: VerbCategory = VerbCategory::Dns;
}

// ── dns.zone.list ─────────────────────────────────────────────────────────

/// Marker type for the `dns.zone.list` verb.
pub struct DnsZoneList;

/// Request body for `dns.zone.list`. Empty — zone listing requires no
/// parameters beyond the adapter's credential scope.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsZoneListInput {}

/// One zone entry in the `dns.zone.list` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsZoneEntry {
    /// Provider-issued zone ID. Opaque; stable within a provider.
    pub id: String,
    /// Zone apex name, e.g. `"yah.dev"`.
    pub name: String,
}

/// Response body for `dns.zone.list`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub struct DnsZoneListOutput {
    pub zones: Vec<DnsZoneEntry>,
}

impl InternalVerb for DnsZoneList {
    type Input = DnsZoneListInput;
    type Output = DnsZoneListOutput;
    const ID: &'static str = "dns.zone.list";
    const CATEGORY: VerbCategory = VerbCategory::Dns;
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn verb_ids_match_canonical_namespace() {
        assert_eq!(DnsRecordUpsert::ID, "dns.record.upsert");
        assert_eq!(DnsRecordList::ID, "dns.record.list");
        assert_eq!(DnsRecordDelete::ID, "dns.record.delete");
        assert_eq!(DnsZoneList::ID, "dns.zone.list");
        for id in [
            DnsRecordUpsert::ID,
            DnsRecordList::ID,
            DnsRecordDelete::ID,
            DnsZoneList::ID,
        ] {
            assert!(id.starts_with("dns."), "{id}");
        }
    }

    #[test]
    fn verbs_are_under_dns_category() {
        assert_eq!(DnsRecordUpsert::CATEGORY, VerbCategory::Dns);
        assert_eq!(DnsRecordList::CATEGORY, VerbCategory::Dns);
        assert_eq!(DnsRecordDelete::CATEGORY, VerbCategory::Dns);
        assert_eq!(DnsZoneList::CATEGORY, VerbCategory::Dns);
    }

    #[test]
    fn upsert_input_defaults_ttl_to_auto_and_proxied_false() {
        let wire = r#"{"zone":"yah.dev","name":"yubaba.yah.dev","type":"CNAME","content":"t.cfargotunnel.com"}"#;
        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
        assert_eq!(parsed.ttl, 1, "default TTL should be 1 (automatic)");
        assert!(!parsed.proxied, "default proxied should be false");
        assert!(
            !parsed.match_content,
            "default must stay match-by-(name,type) — R859-F1 added the field"
        );
    }

    /// R859-F1: a round-robin apex needs upserts keyed on content, otherwise
    /// the second origin overwrites the first.
    #[test]
    fn upsert_input_accepts_match_content() {
        let wire = r#"{"zone":"yah.dev","name":"yah.dev","type":"A","content":"51.81.85.145","match_content":true}"#;
        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
        assert!(parsed.match_content);
    }

    /// R859-F1: pruning one withdrawn origin must not be expressible only as
    /// "delete the A records at the apex".
    #[test]
    fn delete_input_content_filter_is_optional_and_omitted_when_absent() {
        let with_content =
            r#"{"zone":"yah.dev","name":"yah.dev","type":"A","content":"15.204.89.240"}"#;
        let parsed: DnsRecordDeleteInput = serde_json::from_str(with_content).unwrap();
        assert_eq!(parsed.content.as_deref(), Some("15.204.89.240"));

        let bare = DnsRecordDeleteInput {
            zone: "yah.dev".into(),
            name: "yah.dev".into(),
            record_type: Some("A".into()),
            content: None,
        };
        let wire = serde_json::to_value(&bare).unwrap();
        assert!(!wire.as_object().unwrap().contains_key("content"));
    }

    #[test]
    fn record_list_input_omits_absent_filters() {
        let input = DnsRecordListInput {
            zone: "yah.dev".into(),
            ..Default::default()
        };
        let wire = serde_json::to_value(&input).unwrap();
        assert_eq!(wire, serde_json::json!({"zone": "yah.dev"}));
    }

    #[test]
    fn record_list_output_round_trips_with_type_renamed() {
        let out = DnsRecordListOutput {
            records: vec![DnsRecordEntry {
                id: "r1".into(),
                name: "yah.dev".into(),
                record_type: "A".into(),
                content: "51.81.85.145".into(),
                ttl: 1,
                proxied: false,
            }],
        };
        let wire = serde_json::to_value(&out).unwrap();
        assert_eq!(wire["records"][0]["type"], "A");
        assert!(wire["records"][0].get("record_type").is_none());
        let back: DnsRecordListOutput = serde_json::from_value(wire).unwrap();
        assert_eq!(back.records, out.records);
    }

    #[test]
    fn upsert_input_type_renamed_in_wire() {
        let wire = r#"{"zone":"yah.dev","name":"a.yah.dev","type":"A","content":"1.2.3.4","ttl":300,"proxied":true}"#;
        let parsed: DnsRecordUpsertInput = serde_json::from_str(wire).unwrap();
        assert_eq!(parsed.record_type, "A");
        assert_eq!(parsed.ttl, 300);
        assert!(parsed.proxied);
        // Verify the Rust field serializes back as "type".
        let back = serde_json::to_value(&parsed).unwrap();
        assert!(back.get("type").is_some(), "should serialize as 'type'");
        assert!(
            back.get("record_type").is_none(),
            "should not serialize as 'record_type'"
        );
    }

    #[test]
    fn delete_input_type_optional() {
        let with_type = r#"{"zone":"yah.dev","name":"old.yah.dev","type":"CNAME"}"#;
        let parsed: DnsRecordDeleteInput = serde_json::from_str(with_type).unwrap();
        assert_eq!(parsed.record_type.as_deref(), Some("CNAME"));

        let no_type = r#"{"zone":"yah.dev","name":"old.yah.dev"}"#;
        let parsed: DnsRecordDeleteInput = serde_json::from_str(no_type).unwrap();
        assert!(parsed.record_type.is_none());
    }

    #[test]
    fn delete_input_omits_type_when_absent() {
        let input = DnsRecordDeleteInput {
            zone: "z".into(),
            name: "n".into(),
            record_type: None,
            content: None,
        };
        let wire = serde_json::to_value(&input).unwrap();
        assert!(!wire.as_object().unwrap().contains_key("type"));
    }

    #[test]
    fn delete_output_zero_is_not_an_error() {
        let out = DnsRecordDeleteOutput { deleted: 0 };
        let wire = serde_json::to_value(&out).unwrap();
        assert_eq!(wire["deleted"], 0);
    }

    #[test]
    fn zone_list_input_serializes_to_empty_object() {
        let wire = serde_json::to_value(DnsZoneListInput::default()).unwrap();
        assert_eq!(wire, serde_json::json!({}));
    }

    #[test]
    fn zone_list_output_round_trips() {
        let out = DnsZoneListOutput {
            zones: vec![
                DnsZoneEntry {
                    id: "z1".into(),
                    name: "yah.dev".into(),
                },
                DnsZoneEntry {
                    id: "z2".into(),
                    name: "noisetable.com".into(),
                },
            ],
        };
        let wire = serde_json::to_string(&out).unwrap();
        let back: DnsZoneListOutput = serde_json::from_str(&wire).unwrap();
        assert_eq!(back.zones.len(), 2);
        assert_eq!(back.zones[0].name, "yah.dev");
    }

    #[cfg(feature = "json-schema")]
    #[test]
    fn verbs_emit_schemas_via_for_verb() {
        use super::super::VerbDescriptor;

        let upsert = VerbDescriptor::for_verb::<DnsRecordUpsert>();
        assert_eq!(upsert.id, "dns.record.upsert");
        assert!(upsert.input_schema.to_string().contains("content"));

        let delete = VerbDescriptor::for_verb::<DnsRecordDelete>();
        assert_eq!(delete.id, "dns.record.delete");
        assert!(delete.output_schema.to_string().contains("deleted"));

        let list = VerbDescriptor::for_verb::<DnsZoneList>();
        assert_eq!(list.id, "dns.zone.list");
        assert!(list.output_schema.to_string().contains("zones"));

        let records = VerbDescriptor::for_verb::<DnsRecordList>();
        assert_eq!(records.id, "dns.record.list");
        assert!(records.output_schema.to_string().contains("records"));
    }
}