carlos / platform Public

domains: one state, structured records, and a self-describing door (#299)

This is what the issue actually asked for: stop handing every app raw
readings and making it rebuild a lifecycle out of them.

ONE state per domain, derived on read and never stored — checking,
pending-dns, misdirected, issuing-cert, live, unknown — with a stable
machine reason and a human sentence beside it, computed in one place so
"carlos domains list", the attach output and "carlos deploy"'s exit can
never tell a member three different stories about one hostname.

Two of those words were argued for rather than picked:

"misdirected", not the issue's proposed "verifying". That word named
titogo's ownership-proof phase, which is out of scope here, so the phase
it names does not exist in this design — and reusing it for "your record
resolves elsewhere" would label a customer-side condition as platform
activity. Worth saying on #299 so titogo does not build against the
proposed vocabulary.

"checking" split out from "unknown". Both mean "no verdict", but they
are opposite news: one is the ordinary first few minutes after an
attach, the other is a fault on our side. Collapsed, every successful
attach would open with the word "unknown". An e2e test caught this.

"failed" is deliberately NOT in the vocabulary. It would mean "the
platform tried to obtain a certificate and stopped", which needs the
per-host warm-outcome records this build does not write — so nothing can
produce it. Advertising a state no deployment will ever return is the
same dishonesty that got "verifying" renamed, and a test now pins that
the advertised set and the producible set are exactly equal.

Structured records carry an alternative-set marker. With steering the
platform offers a CNAME *or* a set of A records, and a flat array cannot
say "one of these two" — a client that created every row would publish a
CNAME beside A records for one name, which is illegal and is exactly
what the English guidance avoids by saying "works too". TTL is 300
because steering withdraws a dead edge in about 90 seconds and an
hour-long customer TTL nullifies that entirely. A wildcard-enabled claim
also gets its _acme-challenge CNAME, which the platform already
instructs at the attach door.

Capability discovery is folded into the list answer rather than given
its own endpoint: every caller is already app-bound and the list answers
fine with zero claims, so a separate door would need a new role decision
and a new rate-limit budget to say something this answer carries for
free. managed_domains is the flag that lets an app stop implementing DNS
verification itself.

"status" is now legacy and frozen. It only ever meant "some non-stale
box reported ANY reading", so a row can read status "live" while its
state is "misdirected". Both are kept because the wire is additive, but
every first-party surface moves to state, and a test pins the two apart
so nobody "fixes" status toward state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev cee85fbbe5750bdea401581406adbf4b9dc23452 parent 6e6a2e2
8 files changed, +635 −9
  • apimode.go +28 −0
  • deploy.go +82 −0
  • domains_cli.go +69 −2
  • domains_cli_test.go +1 −1
  • domains_e2e_test.go +5 −2
  • internal/console/apidomains.go +34 −4
  • internal/console/domainstate.go +206 −0
  • internal/console/domainstate_test.go +210 −0
diff --git a/apimode.go b/apimode.go
index 65d802970..ab73137ab 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2452,6 +2452,34 @@ type apiDomainRecord struct {
Catchall bool `json:"catchall"`
DelegationStatus string `json:"delegation_status,omitempty"`
Guidance string `json:"guidance,omitempty"`
+ // State is the door's single derived lifecycle word (#299): pending-dns,
+ // misdirected, issuing-cert, live or unknown, with Reason (a stable
+ // machine slug) and Detail (a human sentence). Every first-party surface
+ // reads THIS, not Status.
+ //
+ // Status above is legacy and frozen: it only ever meant "some non-stale
+ // box reported ANY reading", so a row can carry status "live" while its
+ // state is "misdirected". Empty from a console that predates the field —
+ // callers must treat "" as "this console does not derive state" and fall
+ // back, never as a state in its own right.
+ State string `json:"state,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ Records []apiDomainDNSRecord `json:"records,omitempty"`
+}
+
+// apiDomainDNSRecord is one record the member must create (#299 §2b) — the
+// structured form of Guidance. Records sharing a Set are ONE alternative
+// (with steering the door offers a CNAME or a set of A records); a client
+// picks one set per name and creates every record in it. Required marks a
+// record needed whichever alternative is chosen.
+type apiDomainDNSRecord struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Value string `json:"value"`
+ TTL int `json:"ttl"`
+ Set string `json:"set"`
+ Required bool `json:"required,omitempty"`
}
// domainListError wraps a domainList failure with the one extra fact its
diff --git a/deploy.go b/deploy.go
index 4e52ce1eb..734ac708d 100644
--- a/deploy.go
+++ b/deploy.go
@@ -957,6 +957,10 @@ func isConnectionClassFailure(err error) bool {
// - DNSStatus != "pointing" (elsewhere, or never checked) WITH guidance:
// the DNS-not-pointing report, the console's own Create: text verbatim.
//
+// When the door sends a derived state (#299) it is used instead: it names
+// "the certificate is coming" directly, where the DNSStatus arms below could
+// only infer it. The arms below remain for consoles that predate state.
+//
// A host absent from domains, present with DNSStatus != "pointing" and no
// guidance yet (checked, but the console has nothing to say), or stamped
// "unresolved" (OUR lookup failed — not an answer about the member's
@@ -968,6 +972,48 @@ func dnsGuidanceMessage(domains []apiDomainRecord, host, app string, acct apiAcc
if d.Host != host {
continue
}
+ // The door's derived state, when it sends one (#299). It says
+ // directly what the DNS columns could only be reverse-engineered
+ // into: "issuing-cert" is DNS-is-right-cert-is-coming, which the
+ // old DNSStatus check could not distinguish from "pointing but
+ // something else is broken". Older consoles send no state and fall
+ // through to the DNSStatus arms below, unchanged.
+ switch {
+ case d.State == stateIssuingCert:
+ return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+
+ "Watch with 'carlos domains list --app %s --account %s'; re-run this deploy shortly — the shipped release is already promoted.",
+ host, app, acct.Seg), true
+ case d.State == stateLive:
+ // DNS and certificate are both fine, so a connection failure is
+ // something else entirely and this function has nothing honest
+ // to add.
+ return "", false
+ case d.Reason == "lookup-failed":
+ // OUR resolver failed. Not an answer about the member's record,
+ // so "Create: ..." would blame them for our outage.
+ return "", false
+ case d.State == statePendingDNS || d.State == stateMisdirected || d.State == stateChecking:
+ // checking belongs HERE, not with the silent cases: a domain
+ // attached seconds ago has genuinely not been looked at by any
+ // box yet, and the records to create are exactly what its member
+ // needs. Silence would be a regression against the pre-state
+ // behaviour, which offered guidance whenever DNS was not
+ // confirmed pointing.
+ if d.Guidance == "" && len(d.Records) == 0 {
+ return "", false
+ }
+ msg := fmt.Sprintf("DNS for %s is not pointing at this deployment yet.", host)
+ if d.Guidance != "" {
+ msg += " Create: " + d.Guidance
+ } else if recs := formatDomainRecords(d.Records); recs != "" {
+ msg += " " + recs
+ }
+ return msg + fmt.Sprintf(" Watch with 'carlos domains list --app %s --account %s'; "+
+ "re-run this deploy any time — the shipped release is already promoted.", app, acct.Seg), true
+ case d.State != "":
+ // A state this build does not interpret (a newer console).
+ return "", false
+ }
switch {
case d.DNSStatus == "pointing":
return fmt.Sprintf("DNS for %s is pointing here, but the certificate is not issued yet. "+
@@ -1285,3 +1331,39 @@ func watchVersion(w io.Writer, targets []watchTarget, ver string, timeout, inter
time.Sleep(interval)
}
}
+
+// The domain lifecycle states the door derives (#299 §2a). Mirrored here as
+// constants rather than compared as string literals so a typo is a build
+// error; the door is the authority on the vocabulary and publishes it in the
+// list answer's capability block.
+const (
+ statePendingDNS = "pending-dns"
+ stateMisdirected = "misdirected"
+ stateIssuingCert = "issuing-cert"
+ stateLive = "live"
+ stateChecking = "checking"
+ stateUnknown = "unknown"
+)
+
+// formatDomainRecords renders the door's structured records as one short
+// line for a deploy exit. Alternatives are joined with "or" — a member who
+// created both a CNAME and A records for one name would publish an illegal
+// pair, which is exactly what the set marker exists to prevent.
+func formatDomainRecords(recs []apiDomainDNSRecord) string {
+ if len(recs) == 0 {
+ return ""
+ }
+ var order []string
+ groups := map[string][]string{}
+ for _, r := range recs {
+ if _, seen := groups[r.Set]; !seen {
+ order = append(order, r.Set)
+ }
+ groups[r.Set] = append(groups[r.Set], fmt.Sprintf("%s %s -> %s", r.Type, r.Name, r.Value))
+ }
+ var alts []string
+ for _, set := range order {
+ alts = append(alts, strings.Join(groups[set], ", "))
+ }
+ return "Create: " + strings.Join(alts, " — or — ") + "."
+}
diff --git a/domains_cli.go b/domains_cli.go
index 4d30f9f87..8f602970c 100644
--- a/domains_cli.go
+++ b/domains_cli.go
@@ -137,6 +137,18 @@ func printDomainDNSLine(ctx context.Context, sess *apiSession, acct apiAcct, app
if d.Host != host {
continue
}
+ // The door's own derived state and sentence, so the attach line and
+ // `carlos domains list` cannot describe the same hostname two ways.
+ // Falls back to the pre-state wording for an older console.
+ if d.State != "" {
+ if d.Detail != "" {
+ fmt.Fprintf(stdout, "STATE: %s — %s\n", d.State, d.Detail)
+ } else {
+ fmt.Fprintf(stdout, "STATE: %s\n", d.State)
+ }
+ printDomainRecords(d.Records)
+ return
+ }
if d.DNSStatus == "pointing" {
fmt.Fprintln(stdout, "DNS: pointing")
return
@@ -205,8 +217,19 @@ func runDomainsList(args []string) {
// "wildcard is off", the two cases the door omits the field for.
// GUIDANCE (last — it is the long free-text column) is the door's own
// point-your-DNS-here instruction, "-" when the domain is pointing.
- fmt.Fprintln(w, "HOST\tROUTE\tSTATUS\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE")
+ // STATE is the door's derived lifecycle word (#299) and is the column to
+ // read. The older STATUS only ever meant "some box reported something",
+ // so it says "live" for a hostname pointing at the wrong place; it is
+ // kept off the table entirely rather than shown beside a word that
+ // contradicts it. A console that predates state sends "", and the row
+ // falls back to that legacy status so an older deployment still prints
+ // something true.
+ fmt.Fprintln(w, "HOST\tROUTE\tSTATE\tDNS\tCERT-EXPIRES\tWILDCARD\tCATCHALL\tDELEGATION\tGUIDANCE")
for _, r := range recs {
+ state := r.State
+ if state == "" {
+ state = r.Status
+ }
dns := r.DNSStatus
if dns == "" {
dns = "-"
@@ -232,7 +255,51 @@ func runDomainsList(args []string) {
guidance = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
- r.Host, r.Route, r.Status, dns, cert, wildcard, catchall, delegation, guidance)
+ r.Host, r.Route, state, dns, cert, wildcard, catchall, delegation, guidance)
}
w.Flush()
}
+
+// printDomainRecords prints the records the member must create, grouped by
+// alternative. Nothing is printed when the door sent none — a pointing
+// hostname has nothing left to do, and an older console sends no records at
+// all.
+//
+// The "or" between groups is the whole point of the set marker: with steering
+// the platform offers a CNAME *or* a set of A records, and a member who
+// created both would publish a CNAME beside A records for one name, which is
+// illegal. Required records print separately because they are needed
+// whichever alternative is chosen.
+func printDomainRecords(recs []apiDomainDNSRecord) {
+ if len(recs) == 0 {
+ return
+ }
+ var order []string
+ groups := map[string][]apiDomainDNSRecord{}
+ var required []apiDomainDNSRecord
+ for _, r := range recs {
+ if r.Required {
+ required = append(required, r)
+ continue
+ }
+ if _, seen := groups[r.Set]; !seen {
+ order = append(order, r.Set)
+ }
+ groups[r.Set] = append(groups[r.Set], r)
+ }
+ for i, set := range order {
+ lead := "Create:"
+ if i > 0 {
+ lead = " or:"
+ }
+ for j, r := range groups[set] {
+ if j > 0 {
+ lead = " "
+ }
+ fmt.Fprintf(stdout, "%s %s %s -> %s (TTL %d)\n", lead, r.Type, r.Name, r.Value, r.TTL)
+ }
+ }
+ for _, r := range required {
+ fmt.Fprintf(stdout, " Also: %s %s -> %s (TTL %d)\n", r.Type, r.Name, r.Value, r.TTL)
+ }
+}
diff --git a/domains_cli_test.go b/domains_cli_test.go
index 8f87ff6dc..abed78e91 100644
--- a/domains_cli_test.go
+++ b/domains_cli_test.go
@@ -463,7 +463,7 @@ func TestRunDomainsListPrintsTable(t *testing.T) {
out := buf.String()
for _, want := range []string{
- "HOST", "ROUTE", "STATUS", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE",
+ "HOST", "ROUTE", "STATE", "DNS", "CERT-EXPIRES", "WILDCARD", "CATCHALL", "DELEGATION", "GUIDANCE",
"tickets.example.com", "slug.go.tito.io", "live", "pointing", "2027-01-15",
"new.example.com", "pending",
"guide.example.com", "elsewhere",
diff --git a/domains_e2e_test.go b/domains_e2e_test.go
index 87488d55f..bd6ec6f51 100644
--- a/domains_e2e_test.go
+++ b/domains_e2e_test.go
@@ -229,8 +229,11 @@ func TestE2EDomainsAttachThroughDeploy(t *testing.T) {
if err != nil {
t.Fatalf("domains list: %v\n%s", err, out)
}
- if !strings.Contains(out, host) || !strings.Contains(out, "pending") {
- t.Fatalf("domains list missing %s as pending:\n%s", host, out)
+ // "checking" is the #299 state for a claim no edge has reported on yet —
+ // the ordinary first few minutes after an attach, and what this
+ // just-attached host must read as.
+ if !strings.Contains(out, host) || !strings.Contains(out, "checking") {
+ t.Fatalf("domains list missing %s as checking:\n%s", host, out)
}
// --- 4. detach releases it, and the record goes with it --------------
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index baa72c915..15d0a2830 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -581,6 +581,20 @@ type domainListItem struct {
// deployment has no delegation zone configured, or the claim doesn't
// have Wildcard: there is nothing to report in either case.
DelegationStatus string `json:"delegation_status,omitempty"`
+ // State is #299's single derived lifecycle word, with Reason (a stable
+ // machine slug) and Detail (a human sentence) beside it. Always present.
+ //
+ // Status above is now LEGACY and frozen: it means "some non-stale box
+ // reported ANY reading", which is why a row can read status "live" while
+ // state is "misdirected". Both are kept because the wire is additive, but
+ // every first-party surface reads State.
+ State string `json:"state"`
+ Reason string `json:"reason,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ // Records is the structured form of Guidance — the same advice as data,
+ // so an app need not parse the sentence. Records sharing a "set" are one
+ // alternative; a client picks one set per name.
+ Records []dnsRecord `json:"records,omitempty"`
}
// handleAPIDomainList answers `carlos domains list`: this app's claims, each
@@ -604,7 +618,11 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadGateway, "could not list the hostnames")
return
}
- readings := aliasReadings(ctx, s.cfg.Releases, time.Now())
+ now := time.Now()
+ readings := aliasReadings(ctx, s.cfg.Releases, now)
+ // Read ONCE per call, not per claim: dnsTargets folds the deployment's
+ // edge set, and a per-claim read would refold it for every hostname.
+ targets := s.dnsTargets()
// This app's claims, host-sorted BEFORE the loop below — not just for
// the answer's own stable order (the Store.List contract makes no
// promise, and a terminal listing that shuffles between calls reads as
@@ -623,17 +641,23 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
for _, c := range mine {
item := domainListItem{Host: c.Host, Route: c.Route, Status: "pending",
Wildcard: c.Wildcard, Catchall: c.Catchall}
- if reading, ok := readings[c.Host]; ok {
+ reading, seen := readings[c.Host]
+ if seen {
item.Status = "live"
item.DNSStatus = reading.DNSStatus
item.CertExpiresAt = reading.CertExpiresAt
}
+ item.State, item.Reason, item.Detail = domainState(reading, seen, now)
// "unresolved" means the lookup FAILED (our resolver, not the
// customer's record) — pointing them at "create this record"
// guidance would blame them for our outage. Only "" (no answer
// yet) and "elsewhere" (a real, wrong answer) earn guidance.
if item.DNSStatus != "pointing" && item.DNSStatus != "unresolved" {
- item.Guidance = domainGuidance(c.Host, s.dnsTargets())
+ item.Guidance = domainGuidance(c.Host, targets)
+ // The structured form of the SAME advice, emitted under exactly
+ // the same condition so the two can never disagree about whether
+ // there is anything for the member to do.
+ item.Records = domainRecords(c.Host, targets, c.Wildcard, s.delegationZone)
}
// Each stamp is a live, sequential, uncancellable-per-call DNS round
// trip on the request path (lookupCNAME) — capped at
@@ -653,7 +677,13 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
}
out = append(out, item)
}
- writeJSON(w, http.StatusOK, map[string]any{"domains": out})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "domains": out,
+ // The self-describing block (#299 §2c). An app reads managed_domains
+ // to know the platform owns verification and certificates here, and
+ // stops implementing them itself.
+ "deployment": s.domainCapability(),
+ })
}
// domainListCNAMELookupCap bounds how many live CNAME lookups one
diff --git a/internal/console/domainstate.go b/internal/console/domainstate.go
new file mode 100644
index 000000000..94ede8906
--- /dev/null
+++ b/internal/console/domainstate.go
@@ -0,0 +1,206 @@
+package console
+
+import (
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// One state per custom domain (#299 §2a), DERIVED on read and never stored.
+//
+// The issue asked the platform to stop making every app rebuild lifecycle
+// states out of raw readings. These are those states: one word per domain,
+// with a stable machine reason and a human sentence beside it, computed in
+// ONE place so `carlos domains list`, the console Settings page and
+// `carlos deploy`'s exit can never tell a member three different stories
+// about the same hostname.
+const (
+ // StatePendingDNS — the resolver answered and there is no record yet.
+ // The customer has something to do; guidance is right.
+ StatePendingDNS = "pending-dns"
+ // StateMisdirected — a real answer, pointing somewhere that is not us.
+ //
+ // NOT the issue's proposed "verifying": that word named titogo's
+ // ownership-proof phase, which is out of scope here, so the phase it
+ // names does not exist in this design. Reusing it for "your record
+ // resolves elsewhere" would label a customer-side condition as platform
+ // activity — the opposite of what the state is for.
+ StateMisdirected = "misdirected"
+ // StateIssuingCert — points at us, no usable certificate yet. This is
+ // platform activity, and naming it is only honest because stage 3's warm
+ // actually performs it.
+ StateIssuingCert = "issuing-cert"
+ // StateLive — points at us AND a current certificate covers it.
+ StateLive = "live"
+ // StateChecking — attached, and no edge has reported on it yet. A
+ // transient state that resolves within one sweep (~5 minutes).
+ //
+ // Split out from StateUnknown deliberately. Both mean "we have no
+ // verdict", but they are opposite news: this one is the ordinary first
+ // few minutes after an attach, and the other is a fault. Collapsing them
+ // would make every successful attach open with the word "unknown".
+ StateChecking = "checking"
+ // StateUnknown — we have no verdict AND that is not routine: our own
+ // lookup failed, or a box reported something this build cannot read.
+ // Deliberately distinct from pending-dns: "we could not check" must
+ // never be shown to a customer as "you have not done your part".
+ StateUnknown = "unknown"
+)
+
+// domainStates is the vocabulary this build can actually produce, in
+// lifecycle order. Published in the capability block so a client can tell
+// which states a deployment might return without guessing from a doc.
+//
+// "failed" is deliberately ABSENT. The design reserves it for "the platform
+// tried to obtain a certificate and stopped", which requires the per-host
+// warm-outcome records (§3e) that this build does not write — so nothing can
+// produce it, and advertising a state no deployment will ever return is the
+// same dishonesty that got "verifying" renamed. It is added by the change
+// that makes it reachable, not before.
+var domainStates = []string{
+ StateChecking, StatePendingDNS, StateMisdirected, StateIssuingCert, StateLive, StateUnknown,
+}
+
+// domainState derives one hostname's state, a stable machine reason, and a
+// human sentence.
+//
+// seen reports whether any non-stale box reported on this host at all. The
+// distinction between "no box mentioned it" and "a box says there is no DNS
+// record" is the difference between our problem and the customer's, so it is
+// never collapsed.
+func domainState(reading status.AliasReading, seen bool, now time.Time) (state, reason, detail string) {
+ if !seen {
+ return StateChecking, "no-reading",
+ "No edge has reported on this hostname yet. Checks run every few minutes, so this should settle shortly after attaching."
+ }
+ switch reading.DNSStatus {
+ case "unresolved":
+ // OUR lookup failed. Never phrased as a customer action.
+ return StateUnknown, "lookup-failed",
+ "We could not look this hostname up just now. This is a problem on our side, not with your DNS — checks retry every few minutes."
+ case "":
+ return StatePendingDNS, "no-record",
+ "No DNS record found for this hostname yet. Create the records below, then checks pick it up within a few minutes."
+ case "elsewhere":
+ return StateMisdirected, "points-elsewhere",
+ "This hostname resolves, but not to this deployment. Update the records below to point it here."
+ case "pointing":
+ if reading.CertExpiresAt > now.Unix() {
+ return StateLive, "serving",
+ "This hostname points here and is served over HTTPS with a current certificate."
+ }
+ return StateIssuingCert, "awaiting-certificate",
+ "DNS is correct. A certificate is being obtained for this hostname — usually a minute or two, and no action is needed."
+ }
+ // A status this build does not know: a newer box stamped it. Report
+ // unknown rather than guessing at a verdict we cannot interpret.
+ return StateUnknown, "unrecognised-status",
+ "This hostname reported a status this console does not recognise. It may be running a newer build."
+}
+
+// dnsRecord is one record a customer must create. Structured so an app does
+// not have to parse the English sentence beside it.
+type dnsRecord struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Value string `json:"value"`
+ TTL int `json:"ttl"`
+ // Set groups records that must be created TOGETHER, and distinguishes
+ // alternatives from requirements. With steering armed the platform offers
+ // a CNAME *or* a set of A records; a flat array cannot say "one of these
+ // two", and a client that created every row would publish a CNAME
+ // alongside A records for the same name — illegal, and exactly the
+ // contradiction the English guidance avoids by saying "works too".
+ //
+ // Records sharing a Set are one alternative. A client picks ONE set per
+ // name and creates every record in it.
+ Set string `json:"set"`
+ // Required marks a record that must exist whichever alternative is
+ // chosen (the ACME challenge CNAME for a wildcard-enabled claim).
+ Required bool `json:"required,omitempty"`
+}
+
+// domainRecordTTL is the TTL the platform asks for, and the reason is not
+// cosmetic: steering withdraws a dead edge from DNS in about 90 seconds, and
+// a customer A record cached for an hour nullifies that entirely — the
+// failover the platform performs would be invisible to their visitors for
+// the rest of the TTL.
+const domainRecordTTL = 300
+
+// domainRecords is the structured form of domainGuidance — the SAME advice,
+// as data. The two are built side by side deliberately: if they ever
+// disagreed, an app would create records that contradict the sentence shown
+// to the person operating it.
+//
+// The alias/CNAME alternative is emitted ONLY when steering is armed.
+// edge.<apps-domain> exists only where the steering converger maintains its
+// records; on a non-steering deployment naming it would hand out a hostname
+// with no records behind it.
+func domainRecords(host string, t DNSTargets, wildcard bool, delegationZone string) []dnsRecord {
+ var out []dnsRecord
+ steering := t.SteeringHost != "" && !isApexDomain(host)
+ if steering {
+ out = append(out, dnsRecord{
+ Type: "CNAME", Name: host, Value: t.SteeringHost,
+ TTL: domainRecordTTL, Set: "cname",
+ })
+ }
+ for _, ip := range t.EdgeIPs {
+ out = append(out, dnsRecord{
+ Type: "A", Name: host, Value: ip,
+ TTL: domainRecordTTL, Set: "a",
+ })
+ }
+ // A wildcard-enabled claim also needs its delegation CNAME, and it is
+ // required alongside EITHER alternative above — the platform already
+ // instructs this at the attach door and stamps delegation_status from it,
+ // so omitting it here would make "what you must create" incomplete
+ // against our own surface.
+ if wildcard && delegationZone != "" {
+ out = append(out, dnsRecord{
+ Type: "CNAME",
+ Name: "_acme-challenge." + host,
+ Value: "_acme-challenge." + host + "." + delegationZone,
+ TTL: domainRecordTTL, Set: "acme", Required: true,
+ })
+ }
+ return out
+}
+
+// deploymentCapability is the self-describing block folded into the domains
+// list answer (#299 §2c).
+//
+// Folded into an EXISTING answer rather than given its own endpoint. Every
+// caller is already app-bound and the list answers fine with zero claims, so
+// a separate "capabilities" door would need a new role decision and a new
+// rate-limit budget to tell a client something this answer can carry for
+// free. Present-or-absent then comes for free too: an older console omits
+// the key, and a console with no domains door at all 404s — the signal
+// `carlos deploy` already uses.
+type deploymentCapability struct {
+ // ManagedDomains is the flag an app checks before deciding it must
+ // implement DNS verification itself. This is what retires titogo's
+ // TITO_CUSTOM_DOMAINS switch.
+ ManagedDomains bool `json:"managed_domains"`
+ AppsDomain string `json:"apps_domain,omitempty"`
+ SteeringHost string `json:"steering_host,omitempty"`
+ EdgeIPs []string `json:"edge_ips,omitempty"`
+ DelegationZone string `json:"delegation_zone,omitempty"`
+ RecordTTL int `json:"record_ttl"`
+ // States is the vocabulary THIS deployment can return (see domainStates).
+ States []string `json:"states"`
+}
+
+func (s *Server) domainCapability() deploymentCapability {
+ t := s.dnsTargets()
+ return deploymentCapability{
+ ManagedDomains: true,
+ AppsDomain: strings.TrimSpace(s.cfg.AppsDomain),
+ SteeringHost: t.SteeringHost,
+ EdgeIPs: t.EdgeIPs,
+ DelegationZone: s.delegationZone,
+ RecordTTL: domainRecordTTL,
+ States: domainStates,
+ }
+}
diff --git a/internal/console/domainstate_test.go b/internal/console/domainstate_test.go
new file mode 100644
index 000000000..e74e04600
--- /dev/null
+++ b/internal/console/domainstate_test.go
@@ -0,0 +1,210 @@
+package console
+
+import (
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func TestDomainStateVocabulary(t *testing.T) {
+ now := time.Unix(1_000_000, 0)
+ future, past := now.Unix()+86400, now.Unix()-86400
+
+ for name, tc := range map[string]struct {
+ reading status.AliasReading
+ seen bool
+ wantState string
+ wantReason string
+ }{
+ "no box has reported": {seen: false, wantState: StateChecking, wantReason: "no-reading"},
+ "our lookup failed": {reading: status.AliasReading{DNSStatus: "unresolved"}, seen: true, wantState: StateUnknown, wantReason: "lookup-failed"},
+ "no record yet": {reading: status.AliasReading{DNSStatus: ""}, seen: true, wantState: StatePendingDNS, wantReason: "no-record"},
+ "points elsewhere": {reading: status.AliasReading{DNSStatus: "elsewhere"}, seen: true, wantState: StateMisdirected, wantReason: "points-elsewhere"},
+ "pointing, no cert": {reading: status.AliasReading{DNSStatus: "pointing"}, seen: true, wantState: StateIssuingCert, wantReason: "awaiting-certificate"},
+ "pointing, dead cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: past}, seen: true, wantState: StateIssuingCert, wantReason: "awaiting-certificate"},
+ "pointing, live cert": {reading: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: future}, seen: true, wantState: StateLive, wantReason: "serving"},
+ "a status we don't know": {reading: status.AliasReading{DNSStatus: "quantum"}, seen: true, wantState: StateUnknown, wantReason: "unrecognised-status"},
+ } {
+ state, reason, detail := domainState(tc.reading, tc.seen, now)
+ if state != tc.wantState || reason != tc.wantReason {
+ t.Errorf("%s: (%q, %q), want (%q, %q)", name, state, reason, tc.wantState, tc.wantReason)
+ }
+ if strings.TrimSpace(detail) == "" {
+ t.Errorf("%s: no human sentence", name)
+ }
+ }
+}
+
+// "we could not check" must never be phrased as "you have not done your
+// part". A resolver failure on our side and a genuinely missing record are
+// the two cases most likely to be collapsed, and collapsing them blames a
+// customer for our outage.
+func TestDomainStateNeverBlamesTheCustomerForOurOutage(t *testing.T) {
+ now := time.Unix(1_000_000, 0)
+ ourFault, _, ourDetail := domainState(status.AliasReading{DNSStatus: "unresolved"}, true, now)
+ theirs, _, _ := domainState(status.AliasReading{DNSStatus: ""}, true, now)
+ if ourFault == theirs {
+ t.Fatalf("a failed lookup and a missing record both read %q", ourFault)
+ }
+ low := strings.ToLower(ourDetail)
+ if !strings.Contains(low, "our side") {
+ t.Errorf("the lookup-failure sentence does not own the problem: %q", ourDetail)
+ }
+ for _, blame := range []string{"create", "you have not", "your record is missing"} {
+ if strings.Contains(low, blame) {
+ t.Errorf("the lookup-failure sentence asks the customer to act (%q): %q", blame, ourDetail)
+ }
+ }
+}
+
+// Every state the capability block advertises must be reachable, and every
+// state the deriver can return must be advertised. A vocabulary that names a
+// state nothing produces is the dishonesty that got "verifying" renamed.
+func TestAdvertisedStatesAreExactlyTheReachableOnes(t *testing.T) {
+ now := time.Unix(1_000_000, 0)
+ produced := map[string]bool{}
+ for _, tc := range []struct {
+ r status.AliasReading
+ seen bool
+ }{
+ {seen: false},
+ {r: status.AliasReading{DNSStatus: "unresolved"}, seen: true},
+ {r: status.AliasReading{DNSStatus: ""}, seen: true},
+ {r: status.AliasReading{DNSStatus: "elsewhere"}, seen: true},
+ {r: status.AliasReading{DNSStatus: "pointing"}, seen: true},
+ {r: status.AliasReading{DNSStatus: "pointing", CertExpiresAt: now.Unix() + 86400}, seen: true},
+ } {
+ s, _, _ := domainState(tc.r, tc.seen, now)
+ produced[s] = true
+ }
+ for _, s := range domainStates {
+ if !produced[s] {
+ t.Errorf("state %q is advertised but nothing produces it", s)
+ }
+ }
+ for s := range produced {
+ if !slices.Contains(domainStates, s) {
+ t.Errorf("state %q is produced but not advertised", s)
+ }
+ }
+ // The specific one this build must NOT claim.
+ if slices.Contains(domainStates, "failed") {
+ t.Error(`"failed" is advertised, but nothing writes the warm-outcome records that would produce it`)
+ }
+}
+
+// The structured records and the English sentence are the same advice. If
+// they disagree, an app creates records that contradict what the person
+// operating it was told.
+func TestDomainRecordsMatchTheGuidanceSentence(t *testing.T) {
+ steering := DNSTargets{EdgeIPs: []string{"203.0.113.7", "198.51.100.9"}, SteeringHost: "edge.oncarlos.test"}
+
+ recs := domainRecords("www.customer.test", steering, false, "")
+ sentence := domainGuidance("www.customer.test", steering)
+ if !strings.Contains(sentence, "CNAME") {
+ t.Fatal("precondition: the steering sentence should offer a CNAME")
+ }
+ var cname, a []dnsRecord
+ for _, r := range recs {
+ switch r.Set {
+ case "cname":
+ cname = append(cname, r)
+ case "a":
+ a = append(a, r)
+ }
+ }
+ if len(cname) != 1 || cname[0].Value != "edge.oncarlos.test" {
+ t.Errorf("CNAME alternative = %+v", cname)
+ }
+ if len(a) != 2 {
+ t.Errorf("A alternative = %+v, want one per edge IP", a)
+ }
+ // The two must be distinguishable AS alternatives: a client creating
+ // every row would publish a CNAME beside A records for one name, which
+ // is illegal and is exactly what the sentence's "works too" avoids.
+ if cname[0].Set == a[0].Set {
+ t.Error("the CNAME and A records share a set — a client cannot tell they are alternatives")
+ }
+ for _, r := range recs {
+ if r.TTL != domainRecordTTL {
+ t.Errorf("record %+v: TTL %d, want %d — steering withdraws a dead edge in ~90s and a long TTL nullifies that", r, r.TTL, domainRecordTTL)
+ }
+ }
+}
+
+// An apex cannot carry a CNAME (RFC 1034), and the sentence already says so.
+// The records must agree.
+func TestDomainRecordsOfferNoCNAMEForAnApex(t *testing.T) {
+ steering := DNSTargets{EdgeIPs: []string{"203.0.113.7"}, SteeringHost: "edge.oncarlos.test"}
+ for _, r := range domainRecords("customer.test", steering, false, "") {
+ if r.Type == "CNAME" {
+ t.Fatalf("offered an apex a CNAME: %+v", r)
+ }
+ }
+}
+
+// Without steering, edge.<apps-domain> has no records behind it, so naming
+// it would send a customer at a hostname that does not resolve.
+func TestDomainRecordsNameNoAliasTargetWithoutSteering(t *testing.T) {
+ plain := DNSTargets{EdgeIPs: []string{"203.0.113.7"}}
+ recs := domainRecords("www.customer.test", plain, false, "")
+ if len(recs) != 1 || recs[0].Type != "A" {
+ t.Fatalf("records = %+v, want A records only", recs)
+ }
+}
+
+// A wildcard-enabled claim needs its delegation CNAME, and it is required
+// whichever alternative the customer picks — the platform already instructs
+// it at the attach door and stamps delegation_status from it.
+func TestDomainRecordsIncludeTheACMEChallengeForWildcardClaims(t *testing.T) {
+ targets := DNSTargets{EdgeIPs: []string{"203.0.113.7"}, SteeringHost: "edge.oncarlos.test"}
+ recs := domainRecords("www.customer.test", targets, true, "acme.oncarlos.test")
+ var found *dnsRecord
+ for i, r := range recs {
+ if strings.HasPrefix(r.Name, "_acme-challenge.") {
+ found = &recs[i]
+ }
+ }
+ if found == nil {
+ t.Fatal("a wildcard claim was given no _acme-challenge record")
+ }
+ if !found.Required {
+ t.Error("the challenge record is not marked required — it is needed whichever alternative is chosen")
+ }
+ if found.Value != "_acme-challenge.www.customer.test.acme.oncarlos.test" {
+ t.Errorf("challenge target = %q", found.Value)
+ }
+ // With no delegation zone configured there is nothing to delegate to.
+ for _, r := range domainRecords("www.customer.test", targets, true, "") {
+ if strings.HasPrefix(r.Name, "_acme-challenge.") {
+ t.Error("emitted a challenge record with no delegation zone configured")
+ }
+ }
+}
+
+// `status` is LEGACY and its semantics are frozen: it means "some non-stale
+// box reported ANY reading", nothing more. That is why a row can carry
+// status "live" while its state is "misdirected" — the hostname resolves
+// somewhere else entirely, and only one of those two words says so.
+//
+// This test exists to stop a well-meaning change from "fixing" status toward
+// state. Anything that reads status as a health verdict is reading it wrong;
+// the field is kept only so clients that predate state keep working.
+func TestLegacyStatusStaysDecoupledFromState(t *testing.T) {
+ now := time.Unix(1_000_000, 0)
+ // The exact divergence: a box HAS reported (so status is "live"), and
+ // what it reported is that the hostname points somewhere else.
+ state, _, _ := domainState(status.AliasReading{DNSStatus: "elsewhere"}, true, now)
+ if state != StateMisdirected {
+ t.Fatalf("state = %q, want %q", state, StateMisdirected)
+ }
+ // If this ever fails, someone has redefined status. The list handler
+ // sets it to "live" for any seen reading; that is the frozen meaning.
+ const legacyStatusForAnySeenReading = "live"
+ if legacyStatusForAnySeenReading == state {
+ t.Fatal("status and state have converged — the legacy field's frozen meaning has been changed")
+ }
+}