On this page

Public origins

A public origin is a declaration. public_origins in the canonical registry names each hostname this fleet publishes to the open internet: which target publishes it, by which publication, to which local upstream, and which paths that publication carries. stado web origin declares, lists, reports and converges them, and refuses to declare a hostname the public DNS root does not carry.

This is the third of the three choices channels keeps apart — a host-control route, a service endpoint and a public download origin are not one decision. The edge that serves a web product is web hosting's web_api.edge; the one durable origin release clients read is the promise in release.

Why this is declared

On 2026-09-07 every public read under stado://releases/ answered HTTP 503. The durable origin, https://stado.wisent.com/api/release/object, forwarded to https://charless-mac-mini.tail6443b3.ts.net — a value held only in the deployment's STADO_RELEASE_ORIGIN environment variable. Nothing in the product declared that value, validated it, converged it or reported it, so the only way to read which origin the deployment had selected was to provoke a failure and read the origin out of the 503 body.

The node was serving. Tailscale Funnel was enabled on it and already published /api/release/object to http://127.0.0.1:8765/api/release/object. The name was not resolvable: ts.net's own authoritative nameserver, ns1.dnsimple.com, answered NXDOMAIN for it, so nothing outside that tailnet could reach the origin, whatever it was serving. Every public read carried originDiagnosis with state dns_unresolved (channels), and the version-check gate refused with error_code=infra_down.

A hostname published by Funnel becomes public only once Tailscale's control plane creates the public record for the tailnet. The product does not own that record and cannot converge it. So this capability converges what the product does own — the node's own publication of declared paths to a loopback upstream — and reports the rest by name, with the public resolver's own answer beside it.

The declaration

"public_origins": [
  {
    "name": "release-object",
    "hostname": "charless-mac-mini.tail6443b3.ts.net",
    "target": "charless-mac-mini",
    "publication": "tailscale-funnel",
    "upstream": "http://127.0.0.1:8765",
    "paths": ["/api/release/object"]
  }
]
Field What it declares
name A lowercase identifier, unique in the document. It is the argument every stado web origin subcommand takes.
hostname A bare public DNS name — no scheme, no port, no path. What a client outside this deployment resolves.
target The declared registry target that publishes the name. A target the document does not carry is refused.
publication How that target publishes it. tailscale-funnel is the implemented publication.
upstream The loopback address the publication forwards to, http://127.0.0.1:<port>. A public origin forwards to a local service, never to another public name.
paths The absolute paths that publication carries, at least one, none with a trailing slash. A path outside the list is not published by this declaration.

The document is pushed the way every registry change is pushed: validated as a whole, compare-and-swap against the generation counter (registry). It is readable at /api/registry.json.

Declaring one

stado web origin declare release-object \
  --hostname charless-mac-mini.tail6443b3.ts.net \
  --target charless-mac-mini \
  --publication tailscale-funnel \
  --upstream http://127.0.0.1:8765 \
  --path /api/release/object

--path repeats. --json prints the receipt. The write goes through the registry's own validated compare-and-swap, and a hostname with no public A or AAAA record is refused before any write:

refusing to declare public origin "<name>": <hostname> has no public A or AAAA record, so no public edge could fetch it; publish the name first, then declare it

That order is the incident's own lesson. A declaration whose name the DNS root does not carry describes an origin no public edge can fetch, and writing it would publish a promise the fleet cannot keep.

stado web origin list [--json] prints the declarations this registry carries. stado web origin remove NAME [--json] withdraws one. Removing a declaration withdraws the product's claim about the name; it does not unpublish the node.

Reading the report

stado web origin status [NAME] --json emits an array of stado.public-origin-report.v1 rows — one per declaration, or one for the name given — and exits non-zero when any row's verdict is not serving.

[
  {
    "schema": "stado.public-origin-report.v1",
    "name": "release-object",
    "hostname": "charless-mac-mini.tail6443b3.ts.net",
    "origin": "https://charless-mac-mini.tail6443b3.ts.net",
    "target": "charless-mac-mini",
    "publication": "tailscale-funnel",
    "upstream": "http://127.0.0.1:8765",
    "paths": ["/api/release/object"],
    "verdict": "origin-not-public",
    "origin_error": "charless-mac-mini.tail6443b3.ts.net has no public A or AAAA record, so nothing outside this deployment's own network can reach that origin, whatever it is serving",
    "resolution": { "state": "dns_unresolved", "resolver": "…", "answers": [], "detail": "…" },
    "publication_state": {
      "state": "published",
      "published_paths": ["/api/release/object"],
      "missing_paths": [],
      "funnel_enabled": true,
      "detail": "…"
    },
    "edge_selection": {
      "state": "agrees",
      "origin": "https://charless-mac-mini.tail6443b3.ts.net",
      "endpoint": "https://stado.wisent.com/api/release/origin",
      "detail": "…"
    }
  }
]

That row is the incident, reported rather than guessed: the node publishes the path, and the name is still not public. origin_error is the one sentence for the verdict, or null when there is nothing wrong to say.

Each registry, public-edge, actual release-proxy, public-DNS and host-publication read has a ten-second budget. Independent reads run together; a failed source does not discard completed readings. registry_observation, observations, edge_selection.observation and edge_selection.readback_observation retain the operation, source, start and finish times, elapsed milliseconds, budget, state and actual error. A report that cannot complete uses complete: false and diagnostic-incomplete, not a conclusion that the origin is down. If the registry cannot be read, the row still carries the public edge and release-proxy results, but its declaration fields are absent.

Verdicts

verdict What it means
serving Public resolution and publication agree with the selected origin, and a request through the actual public release proxy received an authoritative object response. This is channel evidence, not proof that a particular artifact exists.
origin-undeclared The registry carries no public_origins entry of that name. Nothing was measured, because nothing was declared.
origin-not-public The name has no public A or AAAA record. Nothing outside this deployment's network can reach it, whatever the node is serving.
resolver-unavailable The public resolver could not be asked. That is not evidence the name is missing, and it is never reported as one.
origin-unpublished The name resolves, but the target's publication does not carry every declared path. missing_paths names which.
origin-unreachable The target, edge or origin read-back could not be obtained, failed, or supplied no usable read-back evidence.
origin-mismatch The deployment's own selected origin is not the declared one, so the public edge is fetching something this document does not describe.
diagnostic-incomplete At least one diagnostic source failed or exceeded its read budget. Completed measurements remain available; missing data is not proof that the origin or host is down.

resolution.state

The registry-origin DNS reader reports the three states defined by channels. The deployment's originDiagnosis additionally uses dns_not_required for a literal IP address, with no invented DNS answer.

resolution.state What it means
dns_unresolved The resolver answered, and the name has no public A or AAAA record.
dns_resolved The resolver answered and the name has public records; answers carries them, and the detail names the resolver that said so. It states nothing about whether a client can read the origin: that is what a read reports, and the public edge's own diagnosis carries a second read with the status or the error it got.
dns_unavailable The resolver itself could not be asked. A resolver that did not answer says nothing about the name.

publication_state.state

published when the target's Funnel configuration carries every declared path, unpublished when it carries some or none — missing_paths names them — and unknown when the target could not be asked. funnel_enabled is that node's own answer about Funnel, never an inference from the DNS result.

edge_selection.state

Read from /api/release/origin on the public deployment: agrees when the origin it selected is the declared one, differs when it selected another, undeclared when it has no origin configured at all, and unreadable when that route could not be read.

edge_selection.readback records a request through the public /api/release/object route itself. Stado uses a fresh, valid release coordinate and accepts only that route's authoritative HTTP 404 document naming the same absent URI. The result retains the requested URI, HTTP status, response and actual failure cause. A missing or failed read cannot become serving merely because configuration matches.

edge_selection.diagnosis retains the deployment's complete DNS queries and socket-level origin probe. That probe may use a different HTTP client and must not replace the original proxy request's result. Its success is not sufficient for serving. Stado Desktop shows the actual public release request, every read's source and error, and the full DNS, TCP, TLS and HTTP evidence in Web → Public origins. To prove availability of specific software bytes, run stado storage get URI DESTINATION for that artifact.

Stado 0.18.0 replaces edge_selection.diagnostic_probe with the complete edge_selection.diagnosis object described above; clients that need the same probe read edge_selection.diagnosis.probe. This is a preview JSON contract change, not a registry or persisted-state migration. Rolling back to 0.17.2 restores the old field. The deployment's GET /api/release/origin returns the complete originDiagnosis independently of the installed CLI version.

Release downloads and authenticated object reads, writes and chunk composition resolve the selected origin through the same public DNS reader used by diagnostics. A and AAAA queries run together; an available address family can serve the request even if the other query fails. The original URL, HTTP Host, TLS server name and certificate checks are unchanged, and literal IP origins do not require DNS. The independent socket probe still uses the deployment's system resolver: its ENOTFOUND can therefore coexist with a successful public release request, and neither observation replaces the other.

If no usable address is returned, the actual request records ORIGIN_DNS_UNRESOLVED when DNS answered without addresses, or ORIGIN_DNS_UNAVAILABLE when a resolver request failed. Its cause includes the resolver and hostname. The gateway returns HTTP 503; Stado preserves unavailable, exits 69 and does not save that error as a downloaded artifact.

Converging one

stado web origin converge NAME [--apply] --json makes the target's own publication match the declaration. Without --apply it is a plan and its status is unchanged or refused; nothing is written.

{
  "schema": "stado.public-origin-converge-receipt.v1",
  "name": "release-object",
  "target": "charless-mac-mini",
  "publication": "tailscale-funnel",
  "status": "converged",
  "handlers": [
    { "path": "/api/release/object", "upstream": "http://127.0.0.1:8765/api/release/object", "change": "present" }
  ],
  "funnel": { "enabled": true, "ports": [443] },
  "resolution": { "state": "dns_unresolved", "resolver": "…", "answers": [], "detail": "…" },
  "readback": { "state": "unreachable", "status": null, "detail": "…" },
  "refusal": "the declared origin charless-mac-mini.tail6443b3.ts.net is published by this node's Tailscale Funnel but has no public A or AAAA record, so no public edge can fetch it"
}

status is converged when the node's publication changed, unchanged when it already matched, and refused when convergence cannot produce a public origin — with refusal saying why in one sentence. handlers names each declared path and whether it was already present or was added; readback is the result of reading the origin afterwards, which is unreachable for a name the DNS root does not carry. Convergence does not create that record: the receipt reports the state and refuses, and the tailnet name is published through Tailscale's own control plane.

GET /api/release/origin

A public, bearer-free route on the deployment that answers which origin it selected and attempts a read of that origin's object route without a URI. It serves no object payload. HTTP 200 means the selection and diagnostic document were returned, not that the origin is serving: inspect originDiagnosis.probe.status and .error. The separately identified node:http or node:https probe reports its DNS, TCP, TLS and HTTP phases, actual peer address, timings and structured errors. It does not replace the object proxy's original fetch result.

The probe records phase (configuration, dns, tcp, tls, http or complete), elapsed ms, timings_ms, resolved and connected addresses, connected port, TLS protocol and certificate authorization, and the received HTTP status. A response-body failure retains any status already received. failure and the original request's cause_chain preserve runtime error codes, system call, address, port, nested causes and separate connection attempts. DNS answers alone never assign the failure to TCP or TLS.

The Node requests use a three-second socket inactivity budget, explicitly reported as timeout_kind: socket_inactivity. This is not a total-operation deadline or a DNS lookup deadline. An HTTP error status is a received response; outcome: error or timed_out means the read failed. The Stado CLI applies its own ten-second bound when reading this diagnostic endpoint. Every answer carries cache-control: no-store.

Answer Body
HTTP 200 {"schema":"stado.release-origin-selection.v1","origin":"<selected origin>","originDiagnosis":{…}}. The diagnosis retains each DNS query and its outcome, plus the separate origin probe. States are dns_resolved, dns_unresolved, dns_unavailable, or dns_not_required for a literal IP address.
HTTP 503 {"error":"release origin is not configured"} — the deployment selected no origin at all. The sibling object route answers that same sentence for that same condition.
HTTP 400 {"error":"exactly zero query fields are accepted"} — the route takes no input, so any query field is a caller reading another route's contract.

stado web origin status reads this route and records what it said in edge_selection, which is how a declaration and a deployment that disagree become one reported row instead of two separate investigations.

What this does not do

This capability does not choose a network provider or derive an origin from a host-control route. A release client still reads one durable origin, and the publication provider owns its public DNS records. DNS resolution, a configured Funnel and an equal selected-origin string do not prove that object bytes can be read. Read an exact release coordinate with stado storage get URI DESTINATION or inspect stado storage stat URI --json: an unavailable channel remains unavailable, never an absent release.

Stado Desktop never shells out for any of this. It runs web origin status as a read-only command and web origin converge as a mutation through the authenticated loopback operator route, which carries an argument vector rather than a shell string, and shows the receipt it got back.

Source: this website