How diene runs

1 · Start here

How diene runs

A team makes a repo and publishes a version. Machines do the rest: deploy, DNS, sign-in and failover.

  • ArgoCD draws what should run. Kargo moves one version pointer per stage. DECIDED · owner D3
  • Each landscape has one name with a list of cluster addresses; a 5 s check keeps it current. DECIDED · ARCH:220, 295
  • 3 gaps must be settled before building: see "Must settle". OPEN · GAPS G1, G2, G3

The whole system in one picture

Follow one release from repo to user

Personfleet:landscapes,clustersPersonnitroso.carbon:platformPersonnitroso.tin:serviceCICI: image + 2 chartsKargoKargoArgoCDArgoCDT3T3pichupikachuraichu:opal,rubyampharosDNSRoute 53: mainanswersDNSCloudflare: copy +atomi.cloudClientOur appsClientBrowsersClientOutsidecallersPrimordial (control cluster)Clusters in landscapes

  1. Person People add landscapes and clusters to fleet by reviewed PR. Teams make one repo per platform (nitroso.carbon) and one per service (nitroso.tin). Making the repo is the registration. DECIDED · owner D3; fleet.md:125
  2. CI CI in the service repo publishes an image, an app chart and a primordial chart, all at one version, e.g. 1.0.1. DECIDED · lens-repo-cicd.md:61
  3. Kargo Kargo finds the release and writes the version into one small row file per stage, starting with pichu. TESTED · spike A:111
  4. ArgoCD ArgoCD, on Primordial, makes every cluster match git. Kargo moves to the next stage only when the last one is Healthy. TESTED · spike A:112-125
  5. T3 T3 builds clusters, databases and DNS. Every 5 s it checks each cluster and writes the address list of each landscape name. DECIDED · ARCH:295
  6. DNS Route 53 holds the main answers. Cloudflare holds a copy and the parent zone atomi.cloud. DECIDED · ARCH:227
  7. Client Apps hold one name, their home landscape's, and sign in at that landscape's Logto. On a network error they retry once. DECIDED · ARCH:240-243; lib-contracts:79
  8. If Primordial is down, deploys stop, but traffic and sign-in keep working. DECIDED · ARCH:49-50

What is decided, tested, proposed, open

DECIDED320

owner rulings or the design papers

TESTED74

a test ran it

PROPOSED43

a suggestion; whose is named

OPEN25

you decide

Counted from the tags in the five chapter files. Also 28 marked MISSING (not written anywhere) and 31 NOT TESTED or design only.

Read in this order

  1. 2DeploymentRepos, Kargo, ArgoCD, order
  2. 3Cluster failoverAddress lists, the 5 s loop, swaps
  3. 4DNS failoverThe chain, and what survives
  4. 5Client-side routerRetry, rescue router, address books
  5. 6Frontend failoverWorkers, the Vercel standby
  6. 7Sign-in (Logto)Deploy, tokens, many domains
  7. 8GlobalRegions, home landscape, data
  8. 9Mercury (webhooks)One intake, the 421 rule, 72 h
  9. 10Must settle3 gaps + 48 decisions
  10. 11Whole-system testReal clusters, drill by drill

How to read this site

  • Tags: every statement carries a small chip. Tap it to see its source. DECIDED · example: owner D3
    • DECIDED = an owner ruling or the design papers. TESTED = a test ran it. PROPOSED = a suggestion; whose is named. OPEN = you decide.
    • MISSING = not written anywhere yet. NOT TESTED = designed, never run.
  • Words: a dotted underline means a tool or a word we made up. Tap it for a one-line meaning.
  • Steppers: every flow is a picture you step through: Next, Back, or Play.
  • Colours: one colour per actor, the same everywhere. The name is always written too.

Actors

  • Person
  • CI
  • Kargo
  • ArgoCD
  • T3
  • DNS
  • Client
  • Vendor

Tags

  • DECIDED
  • TESTED
  • PROPOSED
  • OPEN
  • MISSING
  • NOT TESTED

The running example

  • Platform nitroso, service tin.
  • Landscapes pichu (dev) → pikachu (staging) → raichu (prod, Singapore) + ampharos (prod, US).
  • Clusters raichu-opal and raichu-ruby. Virtual landscape mew = raichu + ampharos.

How the chapters connect

  • Deployment puts things on clusters. Cluster failover sends traffic to them.
  • Frontend and sign-in are where a dead DNS name hurts most: a browser's first request, and signing in.
  • Global is cluster failover plus sign-in across regions, plus mercury.
  • The DNS proposal and the Logto test (pass 2) feed the DNS and sign-in screens.

2 · Deployment

How a version reaches users

A team makes a repo and publishes a version. Everything after that is automatic.

  • People edit fleet only to add landscapes and clusters. DECIDED · owner D3
  • ArgoCD draws what should run. Kargo moves one pointer per stage: "tin runs 1.0.1 in pichu". DECIDED · owner D3
  • On real ArgoCD + Kargo (locally): publish → live on the last of 2 stages in 63 s. TESTED · spike A

Who edits what

PersonPeople(reviewedPR)Personnitroso teamPersontin teamfleet/registry:landscapes,clustersnitroso.carbon:platform.yamlnitroso.tin:code + 2chartsT3T3 materializerKargoKargo botfleet/platforms: row files(machines only)ArgoCDArgoCD reads all of itempty rowsversions
  • fleet is one public repo with three folders. DECIDED · owner D2; T3:450
    • registry/: landscapes and clusters. People, by reviewed PR.
    • machinery/: the deploy rules and the compiler chart. Nobody, day to day.
    • platforms/: row files. Machines only: T3 writes empty rows, Kargo writes versions.
  • nitroso.carbon (one per platform): platform.yaml and shared charts. The nitroso team. DECIDED · carbon.md:168
  • nitroso.tin (one per service): code, app chart, primordial chart. The tin team. DECIDED · T3:1003
  • Creating the repo is the registration. No fleet edit. DECIDED · fleet.md:125; T3:1003
  • Carbon and service repos hold no fleet knowledge and no fleet credentials. DECIDED · T3:1063
  • GitHub guard: main needs a reviewed PR; Kargo's bot may bypass; CODEOWNERS on registry/**. DECIDED · fleet.md:609
  • Open: machine files move to their own repo, fleet-state, so people and machines never push to each other's repo. OPEN · picks #4, default yes, untested

The two charts every service ships: DECIDED · owner D3 #5

  • App chart = its pods. Goes to every cluster of the landscape.
  • Primordial chart = what it needs from outside, such as a database. Goes once to Primordial.

One-time setup

One-time setup, in order

PersonBy hand: Primordial, ArgoCD,Infisical, root keyPersonBy hand, once: apply fleet-rootArgoCDArgoCD installs T3 firstT3T3 builds clustersArgoCDBase charts +certificatesT3T3 fills DNST3Platform: Infisical,Logto per landscapeArgoCDServices, frontends,mercuryClientFirst sign-in

  1. Person By hand, once: make the Primordial machine (single-node k3s, secrets via SOPS at boot), install ArgoCD and Infisical, plant the root key. DECIDED · ARCH:893; seed-cluster.md:33
  2. Person Also by hand, outside Primordial so they survive its death: the Cloudflare zone, the Route 53 zones and the R2 "ledger" bucket where T3 keeps its memory. DECIDED · ARCH:125-127; T3:153
  3. Person Apply one file by hand: machinery/fleet-root.yaml. Later registry edits needed no hand apply. DECIDED · fleet.md:48 TESTED · spike A:49
  4. ArgoCD ArgoCD syncs registry/ and machinery/ and installs T3 first; its chart carries the record types. The sync-wave numbers are not written yet. DECIDED · ARCH:893-906 MISSING · sync-wave numbers
  5. T3 T3's cluster controller builds each registered cluster, gets its LB address and registers it in ArgoCD. DECIDED · ARCH:893-906
  6. ArgoCD ArgoCD puts the base charts on each cluster (gateway, secrets sync and more). cert-manager gets TLS certificates. DECIDED · ARCH:893-906
  7. T3 T3's traffic controller fills the DNS address lists once clusters are healthy. DECIDED · ARCH:893-906
  8. T3 First platform: Platform record, then an Infisical project, then one Logto per landscape. DECIDED · ARCH:893-906
  9. ArgoCD Then the other services, frontends, mercury and the edge address books. Then the first user finds a landscape, signs in and gets a home. DECIDED · ARCH:893-906
  • Records park and wait on a status until T3 is up; nobody orders them by hand. DECIDED · T3:15-16
  • T3's controllers turn on in this order: cluster → traffic → platform → dependency → webhook → cf-deploy. DECIDED · T3:3516

Register a landscape and a cluster

Register a landscape, then a cluster

PersonPR: add landscapeampharosPersonPR: add clusterraichu-rubyPersonEach platform optsin: platform.yamlT3T3 builds cluster,fixed LB addressT3T3 writes ArgoCD cluster Secret+ labelsArgoCDArgoCD deploys every service12 s

  1. Person A PR adds fleet/registry/landscapes/ampharos.yaml. DECIDED · ARCH:912
  2. Its region is an airport code from an allow-list, e.g. IAD. A missing (code, provider) pair is refused: RegionUnsupported, nothing built. DECIDED · owner D1; T3:437-444, 874
  3. Person Each platform opts in by adding ampharos to its own platform.yaml. DECIDED · ARCH:912
  4. Person A PR adds fleet/registry/clusters/raichu-ruby.yaml: landscape, mark, provider, cloud account, traffic. DECIDED · fleet.md:211 PROPOSED · field names, MASTER:283
  5. T3 T3 builds it through the cloud's own API (no Crossplane) and gets a fixed LB address. DECIDED · T3:813-816
  6. T3 T3 writes an ArgoCD cluster Secret with labels landscape=raichu, mark=ruby: the only link between the registry and ArgoCD. It also plants one bootstrap secret per platform. DECIDED · T3:816 TESTED · spike A:38 OPEN · O7, default adopt
  7. ArgoCD ArgoCD deploys every service at its current version. Measured: 12 s after the Secret. TESTED · spike A:130
  • Where a cluster's cloud account lives is open. Default: fleet/registry/accounts/<vendor>-<name>.yaml. OPEN · O6, T3:601

Add a platform

Add a platform: just create the repo

PersonTeam creates nitroso.carbonArgoCDAppSet finds *.carbon reposArgoCDPlatformrecordKargoKargoProject,Warehouse,StagesArgoCDAppSets forrowsT3T3: one Infisical project, oneenv per landscape13 s

  1. Person The team creates AtomiCloud/nitroso.carbon from the template. That is all. DECIDED · fleet.md:125
  2. It holds platform.yaml, e.g. landscapes: [pichu, pikachu, raichu, ampharos] and stages: [pichu, pikachu, [raichu, ampharos]]. DECIDED · carbon.md:168
  3. ArgoCD An AppSet that scans for *.carbon repos finds it and makes platform-nitroso. Measured: 13 s. DECIDED · fleet.md:125 TESTED · spike A:56
  4. ArgoCD That deploy has two sources: the compiler chart from fleet@machinery-stable and platform.yaml. It renders the Platform record, one Kargo Project (a Warehouse and Stages per service) and the AppSets that turn row files into deploys. DECIDED · ARCH:695-704; T3:976 OPEN · O1 default
  5. T3 T3 makes one Infisical project for nitroso, one environment per landscape. Carbon's own charts hold what the platform shares. DECIDED · T3:939; owner D9
  • Names shorter than 5 letters are refused. DECIDED · T3:988

Add a service

Add a service: just create the repo

PersonTeam creates nitroso.tin (2charts)T3T3 materializer writes oneempty row per landscapepin: {chart: "", image: ""}ArgoCDArgoCD makes tin's Kargopipeline10 s

  1. Person The team creates nitroso.tin with two charts. No fleet edit. DECIDED · T3:1003
  2. T3 T3's materializer sees the nitroso. prefix and writes one empty row per landscape. DECIDED · T3:1009
  3. The empty row has fixed keys, because Kargo can only change keys that exist: pin: {chart: "", image: ""}. DECIDED · T3:1018-1025
  4. ArgoCD ArgoCD makes tin's Kargo pipeline. Measured: 10 s. TESTED · spike A:166
  5. Nothing deploys yet: an empty pin is quiet, with no errors. TESTED · spike A:106
  • Row layout is open. Tested: platforms/nitroso/landscapes/pichu/tin.yaml. jaeda's pick: platforms/nitroso/tin/pichu.yaml, one folder per service. OPEN · picks #3, default b
  • The service list file services.yaml is dropped by default; the list comes from the row names. The old design was refused by ArgoCD. OPEN · O1, default dropped TESTED · spike A:61-73, 157-169

Release a version

Release tin 1.0.1, stage by stage

CICI publishes 3 artifacts at1.0.1KargoWarehouse: all three equal? →FreightKargoKargo writes pichurowArgoCDArgoCD: primordialonce + app perclusterKargopichu all Healthy?Stops hereKargopikachu rowKargoraichu + ampharosrowsyes

  1. CI CI publishes the image, app chart and primordial chart, all 1.0.1. DECIDED · lens-repo-cicd.md:61
  2. CI CI pings Kargo to look now; otherwise Kargo looks every 5 min. With a ping, Freight came in 3 s. PROPOSED · spike A:213; not yet in T3 TESTED · spike A:173-190
  3. Kargo The Warehouse makes Freight only when all three are 1.0.1. Half a release moves nothing. DECIDED · T3:981 TESTED · spike A:121
  4. Kargo Kargo writes 1.0.1 into pichu's row: clone, edit, commit, push. TESTED · spike A:111
  5. ArgoCD ArgoCD sends the primordial chart to Primordial (once) and the app chart to every pichu cluster. T3 builds or updates tin's database from the primordial chart. TESTED · spike A:112 DECIDED · ARCH:747-751
  6. Kargo Kargo waits until every pichu deploy (clusters and primordial) is synced and Healthy. TESTED · spike A:125
  7. Kargo Kargo writes 1.0.1 into pikachu's row. Only tin's rows change; other services are untouched. TESTED · spike A:113 DECIDED · ARCH:728-735
  8. Kargo Then raichu and ampharos together. Parallel stages were only render-checked. NOT TESTED · render-checked only, spike A:88-91
  9. Kargo pichu never turns Healthy (the test used a missing image). Kargo stops at this stage. TESTED · spike A:118-120
  10. pikachu, raichu and ampharos keep the old version. Nobody has to act fast. TESTED · spike A:118-120
  • Each stage step can be a bare name (automatic, health-gated) or {landscape, gate: manual, soak: 1h, verification}. Only the bare form ran live. DECIDED · fleet.md:410-430 TESTED · spike A:91
  • Frontends ride the same pointer; their primordial chart carries a CloudflareDeploy instead (see Frontend). DECIDED · ARCH:737-746
  • No git webhooks were wired in the test. Production should use them; otherwise budget 30 s per git hop and up to 5 min for Freight. PROPOSED · spike A:189-190
How long each hop took (measured)
HopMeasuredWhat sets it
fleet-root re-applied → platform deploy exists13 sscan every 30 s
T3 empty rows pushed → Kargo pipeline10 s (rows), 25–28 s (old list file)ArgoCD git check every 30 s
registry PR merged → record on Primordial65 sfleet-root re-check
cluster Secret → service Ready on new cluster12 sArgoCD reacts at once
new artifact → Freight98 s to ~5 min; 3 s with a pingKargo minimum 5 min
Freight → pin committed~1 sKargo
pin committed → deploys at new version2–27 sArgoCD git check
pichu Healthy → pikachu promotion~3 sKargo
person's values: edit → live16 sArgoCD git check
publish → last stage Healthy (2 stages)63 sall of the above, with a ping
  • No git webhooks were wired in the test. Production should use them; otherwise budget 30 s per git hop and up to 5 min for Freight. PROPOSED · spike A:189-190 DECIDED · direction for ArgoCD: fleet.md:441

The order of the two charts

Nothing makes the app wait for its database today. One Kargo pin moves the primordial and app deploys in the same promotion. TESTED · spike A:112

tin's very first release: database vs app (gap G3)

KargoKargo writes pichu rowArgoCDPrimordial chart:ask for PostgresArgoCDApp chart on eachclusterT3T3 builds DB, writessecretArgoCDdb-init: DBreachable?VendorSecret sync copiesitRed; retry rule notwrittenno

  1. Kargo Kargo writes 1.0.1 into pichu's row. TESTED · spike A:112
  2. ArgoCD ArgoCD sends the primordial chart (asks T3 for a Postgres) and the app chart at the same time. Nothing orders them. TESTED · spike A:112
  3. ArgoCD db-init runs before the pods update. It cannot reach the database, or its secret does not exist yet: red. DECIDED · rules:665-670; goals/dotnet-api.md:289
  4. What happens next is not written: does the job retry? does ArgoCD retry the sync? MISSING · no syncPolicy.retry in T3, fleet.md or ARCH
  5. T3 Meanwhile T3 builds the database and writes the secret; the secret sync copies it, about 1.5 min on a fresh cluster. TESTED · spike B:61, 132
  6. Kargo The gate could pass on app health alone: ArgoCD has no health rule for the five dependency records. MISSING · only CloudflareDeploy health is defined, T3:3352

What exists instead:

  • Every app chart has a db-init job that runs before the pods update: it checks the database, key-value store and file store, runs schema migrations, seeds data. DECIDED · rules:665-670
  • Pod health checks never depend on the database. DECIDED · rules:689
  • During a database move, schema migrations are refused. DECIDED · T3:2075

jaeda's position on G3 app waits for its database PROPOSED · jaeda, POSITIONS.md

(a) ArgoCD custom health for Database / KeyValue / Cache / Store / Email (Healthy only when the record is Ready and its secret is written); (b) the primordial app syncs before the app chart (sync wave or Kargo step order), and the Kargo stage gate needs BOTH healthy; (c) the app pod waits for its secret (it cannot start without it) and db-init retries with backoff. No human step.

Settings, three layers (last wins)

  1. Chart defaults in the service repo (values.yaml). DECIDED · goals/dotnet-api.md:171
  2. Per-landscape file in the chart, e.g. values.raichu.yaml: type: planetscale in raichu, type: neon in pichu. DECIDED · dotnet-api:171 OPEN · picks #7, default a
  3. Emergency override in the row file's values: block. Short-lived; a check flags any override older than 7 days; Kargo never touches it. Measured: live in 16 s. DECIDED · fleet.md:364-367 TESTED · spike A:114-116
  4. Not tested: how the AppSet picks values.raichu.yaml out of the published chart. MISSING

Rollback

  • Service rollback: promote an older Freight in Kargo. Written for Workers; for normal services it is implied, not written. DECIDED · ARCH:744 MISSING · for normal services
  • Database schema after rollback: not written; a forward migration stays applied. MISSING
    • Proposed: migrations stay backward-compatible for one release ("expand, then contract"). PROPOSED · this chapter, decision 12
  • Deploy rules (compiler chart): the machinery-stable tag never moves back; revert in a new commit and move the tag forward. DECIDED · fleet.md:376-393
  • Break-glass: a person may commit straight to a row file, skipping Kargo, until the next promotion. DECIDED · fleet.md:964; T3:1076

Remove

Remove a service

PersonTeam deletes or archivesnitroso.tinT3T3: OrphanedSource alarm;nothing removedPersonPerson: Decommission record,name typed againT3T3: check, final backup,delete, remove rows

  1. Person The team deletes or archives nitroso.tin. DECIDED · T3:1035-1036
  2. T3 T3 marks OrphanedSource on the Platform and alerts. Rows, deploys and databases stay. DECIDED · T3:1035-1036
  3. Person A person adds a Decommission record naming tin, typing the name again to confirm. DECIDED · T3:172, 1039
  4. T3 T3 checks nothing still uses it, takes a final backup, deletes the outside resources, removes the rows. Which controller removes the rows is not named. DECIDED · T3:172 MISSING
  5. Why so careful: in the test, deleting rows deleted the pods on every cluster and the primordial records. Fix: preserveResourcesOnDeletion: true on every row AppSet. TESTED · spike A:148-153 DECIDED · T3:1049-1050
  • A cluster: traffic off → drain → Decommission. Removing its registry file deletes its ArgoCD entries but keeps its pods. DECIDED · T3:824 TESTED · spike A:136-137
  • A landscape: re-home its users first; platforms drop it from platform.yaml; refused while anything uses it. What enforces that is not written. DECIDED · ARCH:914; fleet.md:162 MISSING

Secrets at deploy time

  • T3 writes app secrets to Infisical at project = nitroso, env = raichu, folder /database/maindb. DECIDED · T3:227
  • Each cluster reads them through a chain that narrows at every step: Primordial store → platform store → service. DECIDED · T3:254
  • Infisical down: running apps keep working; a brand-new service or cluster cannot get secrets until it is back. TESTED · spike B:99

Everything else in this chapter

The 13 holes the test found, and their fixes
#Hole (spike A:192-228)FixIn T3 goal?
1ArgoCD refuses one repo at two versions in one deploydrop services.yaml TESTEDyes, as O1 default (T3:579)
2That failure is silent (looks green)alert on ComparisonErrorno – unmerged fleet edit, not proven
3Deleting rows deletes pods and databasespreserveResourcesOnDeletion TESTED + never delete rowsyes (T3:1050)
4First promotion of a new row failsretry on the ArgoCD step TESTEDno – unmerged fleet edit
5Landscape with zero clusters blocks releases foreverrefuse it up frontno – not proven; who checks is OPEN
6New cluster not health-checked until next releaseT3 re-runs current release on ityes, fix not proven (T3:900, 932)
7Kargo looks for versions only every 5 minCI ping or webhook (3 s, TESTED)no – unmerged fleet edit
8Freight with mixed versionsrequire all three equal TESTEDyes (T3:981-983)
9Shallow merge of values: drops keysdeep merge TESTEDno – unmerged fleet edit
10Empty row must carry the pin keysfixed row shape TESTEDyes (T3:1018)
11Removing a service prunes its Kargo historyremoval only by Decommissionyes (T3:1055)
12Carbon has no image; Warehouse needs onechart-only Warehouseno – not proven
13Kargo objects could race their namespacenamespace at an earlier sync-wave TESTEDno – unmerged fleet edit
  • Source for "unmerged fleet edit": spikes/lock/PR-BODY.final.md:76-85. jaeda's note says "all in the T3 goal" (dm/a/flow.md:34); for 7 of 13 that is not so. see the gaps
What can go wrong, and what happens then
What goes wrongWhat happensWho noticesSource
Half a release publishedno Freight; nothing movesWarehouse statusTESTED
Broken versionstops at its stage; next stage keeps old versionKargo stage stays ProgressingTESTED
Platform deploy breaks quietlyold render stays, shows green, then ComparisonErrornobody, until hole 2's alert existsTESTED
Database not ready on first releasedb-init red; retry not writtennot definedMISSING
Row file deleted by mistakepods + databases deleted, unless the AppSet flag is set—TESTED · + fix
New cluster on a bad versionnot gated until next release—TESTED · gap, fix not proven
Landscape with no clusters in stages:release stuck forever; git says the new version—TESTED · gap
Infisical downrunning apps fine; new services/clusters cannot startalertTESTED
Primordial downno deploys; traffic and sign-in keep workingalertDECIDED · ARCH:49-50
Emergency override forgottenflagged after 7 daysprobeDECIDED
Tested and not tested
ItemStateSource
fleet-root, carbon discovery, row → deploys, empty rows quietTESTED · Gitea stand-in for GitHubspike A claims 1-6
Release 1.0.0 → 1.0.4 with health gate, both waysTESTEDspike A claim 7
Add a cluster, add a second serviceTESTEDspike A claims 8, 9
Drop services.yamlTESTEDspike A claim 11
Manual gate, soak, verification, parallel prod stagesrender-checked onlyspike A:91
Real GitHub org + GitHub App, webhooks, private registryNOT TESTEDspike A:232-235
Real T3 (scripts played it), Decommission, OrphanedSourceNOT TESTEDspike A:237
Row layout by service (tin/pichu.yaml), fleet-state repoNOT TESTEDpicks #3, #4
DB-not-ready ordering on first releaseNOT TESTED · not designed—
Scale (one platform, two services, 4 clusters only)NOT TESTED · beyond thatspike A:238
Open decisions in this chapter
#QuestionDefaultWhose
O1Drop services.yamlyes, list from row namesT3 default (tested)
O6Where a cluster's cloud account is definedfleet/registry/accounts/T3 default = jaeda pick 5
O7One secret store per platform per clusteradoptT3 default (security fix)
O8Which Infisical is the real oneleave as a settingT3 default
P3Row layoutby service: platforms/nitroso/tin/pichu.yaml (test first)jaeda
P4Machine files in their own fleet-state repoyes (test first)jaeda
P6Where young / mature / scaled liveson the platform (platform.yaml), with a checkjaeda
P7Per-landscape settingsin the service's chartjaeda
D-1Who refuses a landscape with zero clusters (hole 5)the compiler chartMASTER:524
D-2Database-not-ready rule on first releasedb-init retries 10 min + ArgoCD health for dependency recordsPROPOSED · this chapter
D-3Schema rollbackexpand-then-contract, one release backward-compatiblePROPOSED · this chapter
D-4Do people keep the two fleet edits (emergency values:, break-glass)?yes, as written in fleet.mdPROPOSED · this chapter

3 · Cluster failover and cluster moves

When a cluster dies or moves

Each landscape has one name with a list of addresses. A check every 5 s drops a dead cluster; apps retry once on another address.

  • Measured: a dead cluster leaves DNS in 13–19 s. TESTED · spike G:141, spike D:109
  • Apps that retry once on another address: 0 failed requests. TESTED · spike D:111
  • Moving to a new cluster = add its address, remove the old one. No name changes. DECIDED · ARCH:317-318

The address list per landscape

NameAnswers withSource
raichu.cluster.atomi.cloudthe LB address of every raichu cluster that is traffic: true and healthyDECIDED · ARCH:220
*.raichu.cluster.atomi.cloudan alias of the line above, so every service rides itDECIDED · ARCH:222
*.opal.raichu… (ops name)one cluster only, for testing before users arriveDECIDED · ARCH:223
<service>.mew.cluster.atomi.cloudthe nearest healthy landscape's addressesDECIDED · ARCH:224, 232
  • Every service name is <module>.<service>.<platform>.<landscape>.cluster.atomi.cloud. DECIDED · ARCH:57-59
    • Example: api.tin.nitroso.raichu.cluster.atomi.cloud.
    • Warning: Another paper writes api.zinc.nitroso.serving.cluster.atomi.cloud instead. The two naming rules clash: gap G1 on Must settle. OPEN · GAPS G1
  • Route 53 holds the main answers. Cloudflare holds a copy and the parent zone atomi.cloud. DECIDED · ARCH:227
  • The controller writes all three: Route 53, the S8 spare, the Cloudflare copy. DECIDED · ARCH:304-306
  • Cache time (TTL): real Cloudflare refused 30 and 59; 60 is the lowest. Route 53 allows 30 s, but that is not tested. TESTED · spike D:103 PROPOSED · jaeda, how:124

Try it: kill a cluster

Tap a cluster to stop it. Watch the loop, the DNS lists and three kinds of client. Time runs 10× faster.

T3Health loop, every 5 sDNSraichu nameDNSampharos nameopalrubyno. 1no. 2ClientOur appClientBrowserClientOutsidecallerraichu clustersampharos clusters

ampharos's two clusters are an example (the proposed minimum is 2 per prod landscape, decision 18).

Time since first failure0 s

Clients

What the loop did

  1. –Nothing yet. Tap a cluster above.

What happens when raichu-opal dies:

TimeWhat happensSource
0 sraichu-opal stops answering.example
5, 10, 15 sProbe 1, 2, 3 fails.DECIDED · T3:2937
15 s3 failed in a row: T3 removes opal's address from the raichu name.DECIDED · T3:2937
about 19 sEvery Cloudflare nameserver serves the new list (p50 3.4 s). Measured end to end: 13–19 s.TESTED · spike D:87; spike G:141
up to 60 s moreDevices that cached the old list forget it (TTL 60).TESTED · spike D:103
  • Our app (retries once): one slow request, then another address: 0 failed. TESTED · spike D:111
  • Browser: tries the next address by itself. DECIDED · ARCH:292 NOT TESTED · real browsers, how:240
  • Outside caller (no retry): on real Cloudflare (TTL 60), errors for 104 s, peak 39.5 %; 6.85 % of requests failed. TESTED · spike D:109-111

One tick of the loop

One tick of the 5-second loop

T3Every 5 sT3Probe each cluster onceT3More than half down?Freeze, page apersonT3Compute wantedrecordsT3Never remove aname's last addressDNSWrite Route 53, S8,Cloudflarenoonly the difference

  1. T3 The traffic controller probes each cluster's gateway health path once, via its LB address, and stamps status.acceptingTraffic. DECIDED · T3:2851
  2. T3 If more than half of all clusters look down at once, it assumes its own network broke: it writes nothing and pages. A person can override. DECIDED · T3:2922-2926 TESTED · spike D:69-71
  3. T3 Otherwise it recomputes every wanted record (landscape lists and every mew-style name) and compares with its last write. DECIDED · ARCH:297-301
  4. T3 Remove after 3 failed probes in a row (15 s); add back after 3 passes plus a hold (60 s, doubling, max 2 min). Never remove the last address of a name. DECIDED · T3:2919, 2937
  5. DNS It writes only the difference, to Route 53, the S8 spare and the Cloudflare copy. One writer at a time (a 10 s lease). DECIDED · ARCH:304-306; T3:3005
  • A person's change (traffic flip, new cluster) skips the wait and lands within one tick. DECIDED · T3:2943
  • Memory in four layers: process memory → a local Redis/memcached → a snapshot in S3 → live DNS as the truth. On start it reads DNS before it writes. DECIDED · owner D7; T3:2894-2907
  • The loop watches itself: a "last good tick" metric pages when stale. DECIDED · T3:3060
  • Primordial down: DNS freezes at its last state and keeps serving. DECIDED · ARCH:308-309
  • Two controllers at once: the lease let one write; the other wrote 0 times. TESTED · spike D:167-176

The traffic switch

  • traffic: true / false is a field in the cluster's registry file, fleet/registry/clusters/raichu-ruby.yaml. DECIDED · ARCH:310
  • A person changes it by PR; the traffic controller acts within one tick. DECIDED · MASTER:287
  • false = leave the address list. The cluster keeps running, so old connections finish. DECIDED · ARCH:311-312
  • true = join the list. This is the switch for a canary and a cutover. DECIDED · ARCH:312-313
  • Destroying a cluster is a separate act (a Decommission record), never implied by false. DECIDED · ARCH:315-316

Swap a cluster (blue/green)

Swap raichu-opal for a new raichu-ruby

PersonPR: add raichu-ruby, trafficoffT3T3 builds itArgoCDArgoCD deploys allservicesPersonDark-verify on ops namePersonPR: ruby traffic onPersonPR: opal traffic offWait 2 h, or 24–48 hT3Gate: under 0.1 %for 1 hPersonDecommission: destroy opalpass

  1. Person A PR adds fleet/registry/clusters/raichu-ruby.yaml with traffic: false. DECIDED · ARCH:913
  2. T3 T3 builds the cluster through the cloud's own API, gets a fixed LB address, registers it in ArgoCD, plants the bootstrap secret. DECIDED · T3:813
  3. ArgoCD ArgoCD deploys the base charts and every service at its current version (12 s after the cluster secret). T3 asks Kargo to re-check the current version on it. TESTED · spike A:130 DECIDED · fix not proven, T3:897
  4. Person Dark-verify: test through the ops name *.ruby.raichu…, with no users yet. Who does it and what it checks is not stated. DECIDED · ARCH:314 OPEN · C-5
  5. Person A PR sets raichu-ruby traffic: true: its address is added. Old and new both serve. DECIDED · ARCH:317
  6. Person A PR sets raichu-opal traffic: false: its address is removed. The cluster keeps running, so open connections finish. DECIDED · ARCH:311-318
  7. Everyone waits: about 2 h on the same provider, 24–48 h when moving to another provider. DECIDED · ARCH:319-320
  8. T3 The decommission gate refuses unless traffic is under 0.1 % for 1 h and no mew-style name would be left empty. DECIDED · T3:824
  9. Person A Decommission record (name typed again) makes T3 destroy raichu-opal. Same database throughout; the cache is not moved. DECIDED · T3:171; ARCH:99; owner D16
  • Each address change was one all-or-nothing call; real Cloudflare served it in about 3.4 s. TESTED · spike D:87
  • Users see nothing: at every moment at least one address is alive. PROPOSED · jaeda, how:176
  • The whole swap has never been run end to end. NOT TESTED · design only, MASTER:503

Machine, cluster or region?

What diesWhat happensHow longSource
One machine (node)the cloud LB stops sending to it; DNS untouchedLB health timeDECIDED · ARCH:291
One clusterthe loop removes its address; browsers try the next; apps retry once13–19 s to leave DNS, then devices forget within the TTLTESTED · spike G:141, spike D:109
A whole region or its databaserestore from backup, move the record to a new placehoursDECIDED · ARCH:293
  • An older paper says "~5–10 s". That cannot happen with 3 failed probes 5 s apart; jaeda proposes saying "13 to 19 s". PROPOSED · jaeda, G11
  • No global load balancer: the 30–60 s DNS lag is accepted. DECIDED · owner D17

What our apps do on normal days

  • The app holds one name: its home landscape's. DECIDED · ARCH:240-241
  • On a network error it retries exactly once, over a fresh connection. Never on an HTTP error reply. DECIDED · lib-contracts:79
  • Browsers try the next address of a name by themselves. DECIDED · ARCH:292 NOT TESTED · real browsers, how:240
  • The rare case, where the name itself is dead, is the rescue router.

Outside callers

  • Callers we do not control (another company's server, a script) have no rescue router. They rely on the short TTL and the 5 s loop: a stale window of up to about a minute. Fine if they retry. DECIDED · ARCH:42-47 PROPOSED · jaeda, how:238
  • Nothing tells partners to retry. MISSING · GAPS G20

Everything else in this chapter

What can go wrong, and what happens then
What goes wrongWhat happensWho noticesSource
A cluster stops answeringremoved after 3 failed probes; apps retry on another addresstraffic controllerDECIDED TESTED
A cluster goes silent (no refusal)apps wait for their connect time limit first; no limit is set anywherenobody yetOPEN · gap
The controller's own network breaksmore than half look down → freeze, write nothing, pageon-call personDECIDED · (spike D:69-71) TESTED · (spike D:69-71)
Most clusters really diefreeze stays on; a person overrideson-call personDECIDED · T3:2926
Two controllers run at oncelease lets one write; the other wrote 0 times—TESTED · spike D:167-176
Primordial diesDNS freezes at last state, keeps serving; edge files freezealertDECIDED · ARCH:308
Only one cluster in a landscape diesno second address; rescue router has nowhere to go in that landscapeusers see errorsPROPOSED · rule: keep 2+ clusters (jaeda, how:281)
Old cluster destroyed too earlygate refuses: < 0.1 % traffic for 1 h AND no empty mew nameT3DECIDED · T3:824
Route 53 downmain and S8 spare both dead; the Cloudflare copy is likely never asked, because the delegation points devices to Route 53usersPROPOSED · reading (jaeda, how:195 was unsure) OPEN · (see 2.8)
A bad address book is publishedallowlist limits harm to our own hosts; issuer never from the book—DECIDED · ARCH:249-253
Tested and not tested
ItemStateSource
Health loop rules (freeze, last address, lease, hold)TESTED · locally and on real Cloudflare DNS, pretend clustersspike G, spike D
Retry once on another address = 0 failedTESTED · with 200 pretend usersspike D:111, spike G:37
Cloudflare write → all nameservers: p50 3.4 s, max 8.3 sTESTEDspike D:87
Route 53 writes, TTL 30, geoproximityNOT TESTED · no AWS tokenhow:127
Rescue router, Docs A/B/CNOT BUILT · NOT TESTEDhow:132
Real browsers trying the next addressNOT TESTEDhow:240
A silent (black-hole) clusterNOT TESTEDhow:240
A full blue/green swap on real clustersNOT TESTED · designMASTER:503
A real Cloudflare rate-limit (429), least-privilege token, IPv6NOT TESTEDT3:3036
Two DNS hosts for one zone (local lab)IN PROGRESS · by Brief 2model/proposal/
Open decisions in this chapter
#QuestionDefaultWhose default
C-1The DNS chain fix (spare name on the same host as main)see PROPOSAL-DNS.mdBrief 2
C-2TTL: 60 s everywhere, or 30 s on Route 53 and 60 s on Cloudflare?60 s everywhere until Route 53 30 s is testedPROPOSED · this chapter
C-3Client connect time limit (for a silent cluster)3 s in every client libraryPROPOSED · jaeda (how:235: 2–3 s)
C-4Minimum clusters per production landscape2PROPOSED · jaeda (how:281)
C-5Who does dark-verify, and what it checksautomated smoke test of every service on the ops name, then a person flips trafficPROPOSED · this chapter
C-6Where the "< 0.1 % for 1 h" number is measuredgateway request metric per cluster in Grafana CloudPROPOSED · this chapter
C-7Cloudflare in front for faster failover than ~104 s without retryno (owner D17: DNS movement)DECIDED · direction; pricing OPEN
C-8Freeze rule: fleet-wide "more than half" or per landscapefleet-wide, as writtenDECIDED · text (T3:2922); scope OPEN for small fleets

4 · DNS failover

When a DNS name itself dies

Cloudflare and Route 53 are a chain, not two spares. Today the spare name sits on Route 53 too, so a Route 53 outage kills both.

  • Cloudflare holds atomi.cloud and points cluster.atomi.cloud to Route 53. DECIDED · owner D36
  • The spare base name (S8) is also on Route 53. DECIDED · ARCH:353-358 PROPOSED · finding: jaeda, how:195
  • The fix is a separate proposal; this screen shows it when it lands. OPEN · owner D36

The chain

VendorRegistrar: who ownsatomi.cloudDNSCloudflare: zone atomi.cloudDNSRoute 53:cluster.atomi.cloudanswersDNSCloudflare copy ofthe answersDNSSpare base name (S8): also onRoute 53ClientA phone or browser asks anamepoints topoints toasked?same host
  • Route 53 down: no answers for the main name. DECIDED · owner D36
  • Cloudflare zone down: the pointer to Route 53 is lost once devices forget it, usually a day or two, so a short Cloudflare outage does not hurt. PROPOSED · jaeda, how.md
  • The design's spare (S8) also lives on Route 53, so a Route 53 outage kills the main name and the spare together. PROPOSED · jaeda, how:195
  • The controller also writes a copy into Cloudflare, but devices are likely never sent there, because the pointer sends them to Route 53. PROPOSED · reading, jaeda; OPEN
  • The domain itself breaking, or a big mistake by us in the records, can also kill the name. Our own mistake is the likeliest; the loop's guards (never remove the last address, freeze) are tested. PROPOSED · jaeda, how.md TESTED · spike D:69-71

Try it: pick what fails

What fails?

One cluster (raichu-opal) is down. For each kind of user:

Our apps (after first load)

Works

Retry once on another address: 0 failed (measured). TESTED · spike D:111

Browser, first visit

Works

Tries the next address by itself; the dead one leaves DNS in 13–19 s. DECIDED · ARCH:292 TESTED · spike G:141

Outside callers

Partly

A stale window of up to about a minute; fine if they retry. PROPOSED · jaeda, how:238

Webhook providers

Works

The intake name drops the dead cluster like any other name. DECIDED · ARCH:297-301

Sign-in

Works

Same name, other cluster, same database. PROPOSED · jaeda, how:205-206

Route 53 is down. For each kind of user:

Our apps (after first load)

Lost

The main name and the S8 spare are both on Route 53. The rescue router has no live name to try. PROPOSED · jaeda, how:195

Browser, first visit

Lost

No answers for the main name. DECIDED · owner D36

Outside callers

Lost

No answers for the main name. DECIDED · owner D36

Webhook providers

Partly

The intake name has no answers; providers retry (often for days). DECIDED · ARCH:342-348

Sign-in

Partly

Signed-in users keep working up to 10 min, then wait; nobody new signs in. PROPOSED · jaeda, how:208-220

This is gap G2. The DNS proposal (pass 2) is meant to fix it.

Cloudflare zone (atomi.cloud) is down. For each kind of user:

Our apps (after first load)

Partly

Fine while devices remember the pointer to Route 53. After that the rescue router moves API calls to the spare base name. DECIDED · ARCH:353-360 PROPOSED · jaeda, how.md

Browser, first visit

Lost

Lost once the pointer is forgotten. Accepted. DECIDED · ARCH:332-334

Outside callers

Lost

Lost once the pointer is forgotten; no rescue router. PROPOSED · reading, jaeda, how.md

Webhook providers

Partly

Providers retry (often for days). DECIDED · ARCH:342-348

Sign-in

Partly

API calls move to the spare name, but sign-in cannot follow: the issuer is baked. DECIDED · lib-contracts:150; ARCH:252 PROPOSED · reading

All of Cloudflare is down. For each kind of user:

Our apps (after first load)

Partly

The rescue router reads the AWS copy of the address book and moves to the spare base name. DECIDED · ARCH:336-339, 350-352

Browser, first visit

Lost

Web runs on Workers: lost, and the error portal too. Accepted. DECIDED · ARCH:336-339

Outside callers

Lost

Lost once the pointer is forgotten. PROPOSED · reading, jaeda, how.md

Webhook providers

Partly

Providers retry (often for days). DECIDED · ARCH:342-348

Sign-in

Partly

Signed-in users up to 10 min, then wait; nobody new signs in. PROPOSED · jaeda, how:208-220

Cloudflare Workers only is down. For each kind of user:

Our apps (after first load)

Works

API calls fine: the API is not on Workers. DECIDED · ARCH:329-332

Browser, first visit

Partly

Down until someone repoints the CNAME to Vercel. Who does it is not written. DECIDED · ARCH:329-332 MISSING

Outside callers

Works

They call the API, which is not on Workers. DECIDED · ARCH:329-332

Webhook providers

Works

Mercury runs in-cluster, no Cloudflare in the path. DECIDED · ARCH:555-556

Sign-in

Works

Logto runs on the landscape's clusters. DECIDED · alog:316-332

Primordial (control cluster) is down. For each kind of user:

Our apps (after first load)

Works

DNS freezes at its last state and keeps serving. DECIDED · ARCH:308-309

Browser, first visit

Works

Traffic keeps working; only deploys stop. DECIDED · ARCH:49-50

Outside callers

Works

Traffic keeps working. DECIDED · ARCH:49-50

Webhook providers

Works

Mercury's config is frozen; intake keeps working. DECIDED · T3:3107

Sign-in

Works

Sign-in keeps working. DECIDED · ARCH:49-50

The whole raichu region is down. For each kind of user:

Our apps (after first load)

Lost

raichu users wait for a restore (hours). The rescue router does not help: it changes addresses, not landscapes. DECIDED · ARCH:254, 293

Browser, first visit

Lost

raichu users wait for a restore. DECIDED · ARCH:293

Outside callers

Lost

Calls to raichu wait for a restore. DECIDED · ARCH:293

Webhook providers

Partly

Providers land in ampharos. Events already queued in raichu wait; no takeover rule. DECIDED · ARCH:302-304 MISSING

Sign-in

Lost

raichu users wait for a restore; nobody new signs in there. DECIDED · ARCH:291-294

jaeda's position until the proposal lands

jaeda's position on G2 DNS chain PROPOSED · jaeda, POSITIONS.md

follow model/proposal/PROPOSAL-DNS.md when it lands. Until then: the traffic controller writes EVERY host in one loop (primary, mirror, spare), the spare base must not share a DNS host with the primary, TTL 60 s on Cloudflare and 30 s where the host allows.

The proposal

Copied from model/proposal/PROPOSAL-DNS.md as it stood at 2026-11-12 07:52 UTC; a rebuild picks up later changes.

Answer first

  • jaeda is right. Today's design is a chain, not two spares. Route 53 down = the main name AND the spare name die. (Details in part 1.)
  • The "Cloudflare copy" in the design cannot answer. While Cloudflare points cluster.atomi.cloud at Route 53, Cloudflare's own copy of those records is hidden. It only helps if someone removes the pointer, and caches then take hours. PROPOSED · reading; see 1.4.
  • Big site practice: github.com and airbnb.com run Route 53 and NS1 at the same time. linkedin.com runs NS1 + Azure DNS. TESTED · : dig NS, 2026-11-12.
  • We cannot do that at the top (atomi.cloud). It is registered at Cloudflare, and Cloudflare-registered names "must remain on Cloudflare nameservers". TESTED · RDAP + DOCS.
  • We can do it one level down. Cloudflare's pointer for cluster.atomi.cloud can list two hosts. No registrar change.
  • Recommendation PROPOSED · levi: option 4.
    1. cluster.atomi.cloud is served by Route 53 + Google Cloud DNS together.
    2. The spare name moves to its own domain, on another TLD, at a registrar that is not Cloudflare, also served by Route 53 + Google.
    3. The traffic controller writes both hosts every tick.
  • Cost (DOCS): about USD 1 to 2 a month more, plus the spare domain (about USD 15 a year, C/concepts/ARCHITECTURE.md:933). Queries are split between hosts, so the query bill barely moves.
  • What it fixes: Route 53 down, AWS down, Google down: users notice nothing except a little extra lookup time.
  • Lab proof TESTED · part 5: one host stopped = 60 % of lookups failed. Two hosts, either stopped = 0 of 3600 failed, slowest 0.7 s.
  • New risk it brings: if the controller cannot write one host, about half the users get a stale address until it is fixed (lab: 16 of 30). Client retry-once covers it.
  • What it does not fix: Cloudflare's zone, the .cloud TLD, or the Cloudflare account dying. Then the main name dies once caches expire. Apps switch to the spare name; browsers on the website cannot. Sign-in renewals and new sign-ins stop (the issuer lives under atomi.cloud), unless Logto also gets a spare address: TESTED, that works with the issuer name unchanged (LOGTO-MULTIDOMAIN.md).
  • Our own bad write hits every host at once. Two hosts never help there; the controller's brakes do.

The options side by side

Hosts compared in part 2.6. "Spare" = the S8 spare base name.

Option 1: keep as is, rely on the rescue router

  • Survives: one cluster dying; Cloudflare DNS for a while (apps use the spare).
  • Does not survive: Route 53 answers, or all of AWS. Main and spare both die.
  • Cost: 0.
  • T3 work: none new.
  • Risk: the one host that holds both names is a single point of failure. The issuer dies too.

Option 2: spare base on another host, registrar and TLD

  • Example: spare atomi-spare.net at a non-Cloudflare registrar, served by Google Cloud DNS. Main stays Route 53 only.
  • Survives: Route 53 down (apps switch to the spare), AWS down, Cloudflare zone, .cloud TLD, Cloudflare account.
  • Does not survive for browsers, new sign-ins, outside callers: they only know the main name.
  • Cost: Google zone USD 0.20 + queries USD 0.40 per million (DOCS https://cloud.google.com/dns/pricing). Spare domain ~USD 15/yr.
  • T3 work: a Google writer for the spare zone.
  • Risk: every Route 53 outage becomes a "rescue" event: slower, and sign-in renewals stop after 10 minutes.

Option 3: two hosts for cluster.atomi.cloud (spare unchanged)

  • How: the Cloudflare pointer lists 4 Route 53 servers + 4 Google servers. Both hold the same records. Resolvers pick one and try the other if it fails.
  • Survives: Route 53 down, Google down, AWS down: for everyone, issuer included.
  • Does not survive: Cloudflare zone / .cloud / Cloudflare account (pointer lost after caches). Spare is still on Route 53 only.
  • Cost: ~USD 0.20 + share of queries.
  • T3 work: write N hosts per tick (part 4).
  • Risk: two hosts can differ for a moment (partial write, part 5 exp. 5). Geo answers differ a little between hosts (2.5).

Option 4: option 3 + spare on its own domain, also on two hosts (RECOMMENDED, PROPOSED by levi)

  • Main cluster.atomi.cloud: Route 53 + Google.
  • Spare cluster.<spare>: other TLD (.net or .com), registrar not Cloudflare, nameservers Route 53 + Google.
  • Survives: any one DNS host, AWS, Google, Cloudflare zone (apps), .cloud TLD (apps), Cloudflare account (apps).
  • Does not survive: Route 53 + Google at once; our own bad write; for browsers and new sign-ins, the Cloudflare/.cloud/registrar cases.
  • Cost: ~USD 0.40 zones + query share + domain ~USD 15/yr.
  • T3 work: as option 3, with 2 zones per host.

Option 5: a third host

  • Example: spare on Google + Bunny (or NS1), main on Route 53 + Google.
  • Gain over option 4: survives Route 53 + Google together. Very rare.
  • Cost: a third API adapter, third geo model, more drift to watch.
  • PROPOSED: not now.

Option 6: two hosts at the top (atomi.cloud itself)

  • Blocked: Cloudflare Registrar names "must remain on Cloudflare nameservers" (DOCS https://developers.cloudflare.com/dns/nameservers/nameserver-options/).
  • Would need a registrar transfer away from Cloudflare plus Cloudflare's "multi-provider DNS" setting (same page; plan NOT STATED).
  • Gain: the website and the issuer would survive Cloudflare DNS too.
  • PROPOSED: not now. Bigger move, touches the website and mail.
2.5 Geo answers on two hosts
  • Route 53 (design): geoproximity, lat/long, bias 0 (C/concepts/ARCHITECTURE.md:232-238).
  • Google: GEO policy keyed by Google Cloud regions; the query's client subnet (ECS) or resolver IP is matched to the nearest listed region (DOCS https://docs.cloud.google.com/dns/docs/routing-policies-overview).
  • Keep answers equivalent PROPOSED:
    • Per landscape, list the Google region closest to it, with the same IP set.
    • When a landscape has no healthy IP: Route 53 deletes its set (design); Google drops that item. Same effect: next-nearest wins.
    • Never use either host's own health checks (design: none in Route 53, C/concepts/ARCHITECTURE.md:238). Google's checks fail "open" when all are unhealthy (DOCS same page), which would cancel our brakes.
  • Effect of a mismatch: near a border, Route 53 may say landscape X and Google Y. Both are healthy, so the user is served either way.
  • Home landscape names (<l>.cluster…) have no geo at all, so most traffic is not affected.
2.6 Host facts (DOCS, checked 2026-11-12 by helper myra; full list in HOSTS/HOSTS.md)
HostLowest TTLAll-or-nothing batchPrice/month*
Route 530yesUSD 4.50 (geo queries 7.50)
Google Cloud DNS1 syesUSD 4.20 (geo queries 7.20)
Cloudflare60 (30 Enterprise)database yes, edge noUSD 0 (+5 for geo)
NS1not statedno batch callUSD 108
Bunnynot statedno batch callUSD 1
Gcore120 free / 30 Prono batch callUSD 0 / 2.49
DigitalOcean30no batch callUSD 0 (no geo, no DNSSEC)

*1 zone, 50 records, 10 million queries a month, no health checks. Each host's URLs: HOSTS/HOSTS.md.

Geo by host: Route 53 country/continent/lat-long/latency; NS1 country/state/lat-long; Bunny geo + latency; Gcore geo/network; Google only by Google Cloud region; Cloudflare only via its paid load balancer.

Why Google as the second host PROPOSED · levi:

  • It is the only other host with a documented all-or-nothing batch call, like Route 53. Partial writes inside one host cannot happen.
  • 100 % SLA (https://cloud.google.com/dns/sla). Bunny and Gcore publish none (HOSTS/HOSTS.md).
  • Weak spot: geo only by Google region (2.5).
  • Second choice: NS1. The big-site pattern (github, airbnb), best geo, but USD 108 a month and no batch call.
  • Cheap choice: Bunny (USD 1) or Gcore. No batch call, no SLA. Fine for a lab or the spare only.
  • Route 53 rule that matters: a second change to the same zone while one is still running is refused (PriorRequestNotComplete). Limits: 10 requests/s per account (DOCS https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html).
  • Route 53 DNSSEC: "multi-vendor configurations are not supported" when signing (DOCS https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-configuring-dnssec.html). Google: multi-signer NOT STATED.
  • So two hosts means no DNSSEC on cluster.atomi.cloud. atomi.cloud is not signed today anyway TESTED. The T3 draft already notes this limit (T3/:3039-3043). OPEN only if the owner wants DNSSEC.
  • Zone transfer (one host copying from another): Route 53 none; Google none; Cloudflare Enterprise only; NS1 yes (HOSTS/HOSTS.md). So T3 writes each host by API; no copying between hosts.
  • SLA: Route 53 100 % (https://aws.amazon.com/route53/sla/); Google 100 % (https://cloud.google.com/dns/sla).

What still works, per option

Pick an option, then read down: what still works when each thing fails.

How to read it: Works Until caches expire Dead Via the spare (apps)

People: Browser, first visit · Signed-in app · New sign-in · Outside caller.

Note on S and N: the tables assume today's design (no spare address for Logto). Signed-in apps then work up to 10 minutes on a dead issuer, then wait (C/concepts/ARCHITECTURE.md:423-427). With the Logto spare address (4.4), apps keep renewing and signing in via the spare.

Option 1 (as designed)

Route 53 fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

Cloudflare DNS fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

Cloudflare whole fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

AWS whole fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

.cloud TLD fails

Main nameUntil caches expire
Spare nameWorks if not .cloud
Browser, first visitUntil caches expire
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

Cloudflare account fails

Main nameDead
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inDead
Outside callerDead

our bad write fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

Primordial fails

Main nameWorks frozen
Spare nameWorks frozen
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Option 2 (spare elsewhere)

Route 53 fails

Main nameDead
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inDead
Outside callerDead

Cloudflare DNS fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

AWS whole fails

Main nameDead
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inDead
Outside callerDead

.cloud TLD fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitUntil caches expire
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

Cloudflare account fails

Main nameDead
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inDead
Outside callerDead

our bad write fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

Primordial fails

Main nameWorks frozen
Spare nameWorks frozen
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Option 3 (two hosts for main)

Route 53 fails

Main nameWorks
Spare nameDead (not needed)
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Google fails

Main nameWorks
Spare nameWorks
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Cloudflare DNS fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

AWS whole fails

Main nameWorks
Spare nameDead
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

.cloud TLD fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitUntil caches expire
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

Cloudflare account fails

Main nameDead
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inDead
Outside callerDead

our bad write fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

Primordial fails

Main nameWorks frozen
Spare nameWorks frozen
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Option 4 (two hosts for main and spare; RECOMMENDED)

Route 53 fails

Main nameWorks
Spare nameWorks
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Google fails

Main nameWorks
Spare nameWorks
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Cloudflare DNS fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

Cloudflare whole fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

AWS whole fails

Main nameWorks
Spare nameWorks
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

(AWS clusters leave DNS)

.cloud TLD fails

Main nameUntil caches expire
Spare nameWorks
Browser, first visitUntil caches expire
Signed-in appVia the spare (apps)
New sign-inUntil caches expire
Outside callerUntil caches expire

Cloudflare account fails

Main nameDead
Spare nameWorks
Browser, first visitDead
Signed-in appVia the spare (apps)
New sign-inDead
Outside callerDead

Route 53 + Google fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

our bad write fails

Main nameDead
Spare nameDead
Browser, first visitDead
Signed-in appDead
New sign-inDead
Outside callerDead

Primordial fails

Main nameWorks frozen
Spare nameWorks frozen
Browser, first visitWorks
Signed-in appWorks
New sign-inWorks
Outside callerWorks

Notes:

  • B under Cloudflare: the website itself runs on Cloudflare Workers; its Vercel standby needs a Cloudflare DNS edit, so it is lost DECIDED · accepted: C/concepts/ARCHITECTURE.md:328-335.
  • "AWS whole" also removes CloudFront's copy of the address books; the Cloudflare R2 copy stays (C/concepts/ARCHITECTURE.md:256-266).
  • "Primordial": the controller stops, DNS freezes at its last good state DECIDED · C/concepts/ARCHITECTURE.md:307-308. A cluster dying during that time stays listed; clients retry another address.
  • "our bad write": brakes cap the damage at one cluster per tick and never empty a name (T3/:195-199, T3/:3061-3065).
  • until caches expire length depends on the pointer TTL we set; part 5 exp. 4.
1. The chain today

1.1 Drawing

registrar: Cloudflare Registrar        (TESTED RDAP)
   |
root (.)  --NS 172800 s-->  .cloud TLD (registry Identity Digital; servers trs-dns, Tucows)  (TESTED dig)
   |
.cloud    --NS 900 s----->  atomi.cloud on Cloudflare (tim/brianna)        (TESTED dig)
   |                        Cloudflare's own NS TTL: 86400 s
   |                        "does not exist" cached 1800 s (SOA minimum)
   |                        not signed (no DNSSEC)                          (TESTED RDAP)
   |
atomi.cloud --NS (TTL ours)--> cluster.atomi.cloud on Route 53             (DECIDED design, NOT BUILT)
                               Route 53 NS TTL usually 172800 s
                               |
                               +-- <l>.cluster.atomi.cloud    A  TTL 60  (LB IPs of healthy clusters)
                               +-- *.<l>.cluster.atomi.cloud  CNAME -> it
                               +-- api.lithium.aldehyde.<l>.cluster.atomi.cloud = the Logto issuer

spare (S8): <l>.cluster.<backup-base> on Route 53, same IPs            (DECIDED design, NOT BUILT)
            domain, TLD and registrar not chosen yet                    (OPEN)

Sources for the design lines:

  • Route 53 primary, Cloudflare "SECONDARY/mirror": C/concepts/ARCHITECTURE.md:226-229, T3/:139-140.
  • TTL 60: T3/:2859-2861 (newer, after spike D). ARCHITECTURE says "30-60s": C/concepts/ARCHITECTURE.md:230-231 (older, 2026-10-29).
  • Spare base S8: C/concepts/ARCHITECTURE.md:353-362, C/goals/fleet.md:133-139.
  • Issuer name: C/goals/aldehyde-logto.md:99-100.
  • Today: cluster.atomi.cloud has no NS records yet TESTED · dig @tim.<wbr>ns.<wbr>cloudflare.<wbr>com cluster.<wbr>atomi.<wbr>cloud NS = no answer. Nothing is built.
  • Route 53 NS TTL: not stated in its docs page (DOCS https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/SOA-NSrecords.html). Seen live: 172800 on reddit.com and hashicorp.com, other values on zones that changed it TESTED · dig.
  • Route 53 "does not exist" cache = the smaller of SOA TTL (900) and SOA minimum (86400 by default) = 900 s (DOCS same page).

1.2 Sources disagree (both shown)

  • ARCHITECTURE (2026-10-29): Route 53 is primary, Cloudflare is the mirror (C/concepts/ARCHITECTURE.md:226-229).
  • C/goals/fleet.md:134-136 says the spare "mirrors the CF cluster.atomi.cloud A-set", as if Cloudflare served it.
  • T3 draft (newer, PR 71): the loop "diffs against cached CF state, and writes CF only on delta" (T3/:2853-2856), but its credentials still say Route 53 primary (T3/:139).
  • None of them says how resolvers reach Route 53. The only way is a pointer (NS records) in the Cloudflare zone. jaeda assumed that. So did I. OPEN: confirm.

1.3 Which outage breaks what, and when

What diesMain nameSpare nameWhen
Route 53 answersdeaddeadwithin 60 s (record TTL)
Route 53 writes only (like AWS us-east-1, 2025-10-20)frozen, still answersfrozenat once; dead clusters stay listed
Cloudflare DNSworks while caches hold the pointerworkscold resolvers at once; warm ones after the pointer TTL
.cloud TLDworks while caches hold atomi.cloudworks (if not .cloud)900 s to 1 day
Cloudflare account (registrar + DNS)lost or hijackedworks (if another registrar)at once
  • Route 53 writes: DOCS: during a us-east-1 problem "DNS resolution and health checks ... continue", but record changes may fail. Opt-in "accelerated recovery" moves writes to another region. https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/accelerated-recovery.html
  • Cloudflare DNS: the pointer TTL is ours to set. In the lab a warm resolver kept working for that TTL, then failed; a fresh one failed at once (part 5). Whether real resolvers keep Route 53's own NS list (172800 s) longer: NOT TESTED.

1.4 Why the Cloudflare "mirror" cannot answer PROPOSED · reading

  • DNS rule: once a zone points a name at other servers, data under that name in the parent is not served (RFC 1034 §4.2.1, "occluded"). Cloudflare behaviour on this: NOT TESTED.
  • So the mirror only works if a human deletes the pointer. Resolvers keep the old pointer for its TTL, so it takes minutes to days.
  • Keeping it costs writes and risk (a stale mirror going live by mistake). PROPOSED: drop the mirror and use a real second host instead. OPEN.
4. What changes, if the owner says yes to option 4 (PROPOSED)

4.1 Order to build

  1. OPEN, owner: pick the spare domain name, TLD (not .cloud) and registrar (not Cloudflare).
  2. Create the Google Cloud DNS zones: cluster.atomi.cloud and the spare zone. Set SOA minimum low (60 to 300 s) on every host, so a too-early lookup is not cached as "missing" for long.
  3. Build the controller's host interface + Route 53 and Google writers. Run the lab test against them.
  4. Fill both hosts; check every nameserver of both hosts serves every name.
  5. Only then add Google's 4 servers to the Cloudflare pointer for cluster.atomi.cloud, and set both hosts at the spare's registrar.
  6. Edge docs, allow-list, gateway names and certificates for the spare (4.3).
  7. Sign-in changes (4.4).

4.2 Traffic controller

  • One loop, unchanged: probe once, decide once, brakes once (on the wanted state, never per host).
  • Then one write per host, in parallel. Each host's write is all-or-nothing on that host (DOCS above). There is no all-or-nothing across hosts.
  • Each host keeps its own "last known live" copy. The diff is per host.
  • A host that fails: alert HOST_WRITE_FAILED, retry it next tick with its own diff, keep writing the others.
  • Every refresh: read every host; compare each with the wanted state and with each other; alert HOSTS_DIFFER; repair.
  • Route 53: one change at a time per zone; wait for INSYNC before the next.
  • A host down for long: page a human. Never edit the Cloudflare pointer automatically.
  • New names: create on all hosts, wait until every server of every host answers, then use (spike D rule, T3/:2989-2996, now for N hosts).
  • Lease: unchanged, one leader writes all hosts (T3/:3003-3015).
  • Drop the Cloudflare "mirror" writes (1.4). OPEN.
  • Set the Cloudflare pointer TTL for cluster.atomi.cloud long PROPOSED · 86400 s, so short Cloudflare DNS outages stay invisible to warm resolvers (lab: they last exactly that TTL).
  • Stale-host rule PROPOSED: a host that misses writes for 5 minutes pages a human; the controller keeps retrying it. Half of fresh lookups may get stale addresses meanwhile (lab exp. 5), so client retry-once stays required.

4.3 Rescue router, edge documents, gateways

  • Doc C candidate list: main address first, spare after (already designed, C/concepts/ARCHITECTURE.md:266-272). Only the spare root changes.
  • Baked allow-list: add the spare root (C/concepts/ARCHITECTURE.md:248-253).
  • Doc A host "CloudFront via the R53 rescue domain": becomes "via the spare domain" (now on two hosts).
  • Gateways: the spare wildcard certificate exists in the design (C/goals/charts/kgateway.md:149-154). Gap: routes must also accept the spare host names. Not stated in the corpus.

4.4 Sign-in (the issuer)

  • The issuer api.lithium.aldehyde.<l>.cluster.atomi.cloud lives in the main zone. Option 4 keeps it alive through Route 53, Google or AWS dying.
  • It still dies with Cloudflare's zone, .cloud or the Cloudflare account.
  • Fix PROPOSED · TESTED on a local Logto: add a spare address api.lithium.aldehyde.<l>.cluster.<spare> for the same Logto; keep the issuer string unchanged; client and service libraries fall back to it. Then S and N in the matrix become via the spare for apps. Details: LOGTO-MULTIDOMAIN.md option (iii).
5. Local lab (TESTED, LAB/REPORT.md)

Built by helper alan, 2026-11-12. Real servers in docker: NSD as the parent, PowerDNS as host 1, BIND as host 2, unbound 1.22 as the resolver. The spike D controller, changed to write both hosts each tick. A client sends 20 lookups a second. One run per test. USD 0. All cleaned up (LAB/CLEANUP.txt).

TestOne hostTwo hosts
Host stopped: lookups failed1798 of 3000 (60 %)0 of 3600
First failure after stop45 s (cached answers ran out)none
Slowest lookup12 s timeout0.7 s (p99 under 4 ms)
Fresh resolver (first visit)no answer in 16 sanswer in 0.38 s
  • Parent stopped (= Cloudflare zone dead; both child hosts fine):
    • Pointer TTL 300 s: warm resolver kept working 294 s, then failed.
    • Pointer TTL 60 s: kept working 50 s.
    • A fresh resolver failed at once in both runs.
    • So the pointer TTL we set in Cloudflare = how long a Cloudflare DNS outage stays invisible. PROPOSED: set it to 86400 (one day). Cost: adding or removing a host then takes a day to reach everyone.
    • NOT TESTED: whether resolvers keep the child's own NS list longer (Route 53 uses 172800 s).
  • Partial write (controller could not write host 2; a cluster died):
    • Host 1 updated in 14 s. Host 2 kept the dead address.
    • 16 of 30 fresh resolvers got the dead address (they happened to ask host 2). 0 lookups failed: the answer was valid, just stale.
    • The controller raised HOST_WRITE_FAILED every tick and HOSTS_DIFFER; it fixed host 2 4.9 s after the path came back.
    • Meaning: a stuck host = about half the users get stale addresses until it is fixed. Client retry-once on another address covers it (spike D: 0 % failed with retry).
  • Cluster death, both hosts writable: both hosts updated by 14.2 s; last stale answer 39 s after death. 0 failed lookups.
  • Each host stays all-or-nothing: a bad item made PowerDNS (HTTP 422) and BIND (NOTZONE) apply nothing. There is no all-or-nothing across hosts.
  • Brakes held with two hosts: last address kept, freeze on mass failure, stable re-add.
  • Differs from real life: no anycast, no real geo, no real API delay, one resolver type, no DNSSEC, no lease.
6. Not tested
  • Real Route 53, real Google Cloud DNS, real public resolvers with two hosts (needs owner tokens).
  • Cloudflare's behaviour with records under a pointer (1.4).
  • Real geo answers on two hosts.
  • DNSSEC with two hosts.

Facts that shape any fix

  • A name looked up before it exists is remembered as "no such name" for 1,800 s on our zone. TESTED · spike D:106
  • DNS changes reach users within about TTL + 3 s (TTL 60 on Cloudflare: worst 57 s on 8.8.8.8). TESTED · spike D:93-102
  • Cloudflare write → all its nameservers: p50 3.4 s, max 8.3 s. TESTED · spike D:87
  • Route 53 writes, TTL 30 and geoproximity are not tested (no AWS token). NOT TESTED · how:127
  • A third DNS host only helps if Route 53 and Cloudflare are both down. PROPOSED · jaeda, how.md
Other DNS hosts, and keepalived

Yes, there are other DNS hosts. What we need from one: an API, a short cache time (30 s), and routing by location.

DNS hostBy locationNote
Route 53 (main, decided)yespay per zone and per query
Cloudflare (copy, decided)paid add-on60 s lowest cache time below Enterprise
Google Cloud DNSyesclose match to Route 53
NS1 (IBM)yes, the strongestcostly
Bunny DNSyesvery cheap
Gcore DNSyescheap
DigitalOcean DNSnofree, we already have the account
  • From memory, not checked today. Exact lowest cache times and prices need a look before any swap.
  • A third host only helps if Route 53 and Cloudflare are both down. The rescue router already covers that case.

keepalived: lets two machines share one address. If the first dies, the second takes the address in 1 to 3 seconds.

  • It only works inside one network, like one rack or one data centre.
  • Clouds block the trick it uses; they give you their own load balancer instead, which does the same job. That is our "a node dies, the load balancer moves on" step.
  • It cannot move an address between regions or between clouds. So it does not help with a dead cluster.
  • Useful only if we ever run our own bare machines.

5 · Client-side router

The rescue router in our apps

It sleeps. It wakes only when a name cannot connect at all, then tries spare addresses from a saved address book.

  • Designed, not built and not tested. NOT TESTED · how:132
  • It never changes the landscape or the sign-in address. DECIDED · bun-api-engine.md:99; ARCH:252-253
  • Every address must end in one of our own domains, or the whole book is refused. DECIDED · bun-frontend-utils.md:54

Two things share the word "failover"

Address list updateSwitch to a spare name
Whena cluster dies or is replacedour DNS name itself is dead
Whothe traffic controller, automaticonly the rescue router in the app
How oftenroutinerare
Sign-in affectednoyes (see Sign-in)
  • Source: jaeda's wording. PROPOSED · jaeda, how:184-188

The three edge files

T3T3 writes them from recordsVendorCloudflare R2VendorAWS S3 + CloudFrontDoc A: wherethe booksareDoc B: whichlandscapesDoc C:address bookClientOur app keeps the last goodcopy
FileHoldsWhen the app reads it
Doc Athe list of places that hold the books (~200 bytes)all the time, in the background
Doc Bwhich landscapes a platform runs in (names only)at sign-in, when the user has no home yet
Doc Cthe full address book per service, main first, spare afteronly when the rescue router wakes
  • T3 writes them from existing records; nobody edits them. DECIDED · ARCH:256-283; T3:2863
  • Each file is written twice: Cloudflare R2, and AWS S3 behind CloudFront. Also reachable on *.cloudfront.net, which needs none of our DNS. DECIDED · T3:2878; ARCH:261-264
  • No signing; the baked allowlist replaces it. DECIDED · ARCH:282-283
  • Each copy of Doc A lists the other places, so the set of places can grow without a new app release. DECIDED · ARCH, via how.md

Walk: a client when its address dies

A client when its address dies

ClientRequest fails to connectClientRetry once, fresh connectionClientRescue router wakesClientRead saved Doc CClientAddress ends in our domains?ClientYes: try the nextaddressNo: refuse the wholebookDone: the call goes throughfails again

  1. Client The app calls api.tin.nitroso.raichu.cluster.atomi.cloud. The connection fails: no answer at all. DECIDED · ARCH:240-241
  2. Client It retries exactly once, over a fresh connection. Browsers also try the name's next address by themselves. Most outages end here. DECIDED · lib-contracts:79; ARCH:292
  3. Client The connect fails again, so the rescue router wakes. Any HTTP reply would not wake it. DECIDED · lib-contracts:76
  4. Client It reads the address book (Doc C) saved on the device: main address first, spare after. It keeps the last good book and never takes an older one. DECIDED · ARCH:247-249, 271-277
  5. Client Every address must end in one of our own baked roots (*.cluster.atomi.cloud plus rescue roots), checked at use time. DECIDED · bun-frontend-utils.md:54
  6. Client It tries the next address. It changes only the address, never the landscape, and never the sign-in address. DECIDED · bun-api-engine.md:99; ARCH:252-253
  7. Client One address does not end in our domains, so the whole book is refused. A bad book can only point at our own hosts. DECIDED · bun-frontend-utils.md:54; ARCH:249-253

What it survives

What breaksWhat still works
Our own DNS zoneCloudFront's own name (needs none of our DNS)
All of Cloudflarethe AWS copy
All of AWSthe Cloudflare copy
Primordial (our control cluster)the books freeze at the last state; apps still fail over
The app is offline or starts coldthe book saved on the device
  • Source: jaeda's reading of the design. PROPOSED · jaeda, how.md

What it does not survive: PROPOSED · jaeda, how.md

  • Cloudflare and AWS down together.
  • A dead home region: that is data to restore, not an address problem. DECIDED · ARCH:254
  • An app unopened so long that every place on its old list is gone. Rule needed: keep at least one place alive forever.
  • A wrong book published by us: limited to our own domains, and the sign-in address is never taken from a book.

Where it runs

  • On: Flutter apps and browser code. Off: the nextjs server; its rescue is a redeploy. DECIDED · ARCH:244-246; nextjs:165-168
  • Built by bun frontend-utils (browser) and dart-api-engine (Flutter). Both have test rows. DECIDED · bun-frontend-utils.md:280; dart-api-engine.md:187
  • A scheduled test exercises the sleeping path so it does not rot. DECIDED · bfu:128
  • Not written: where the browser stores the book, and whether spare names have valid certificates and CORS for the web page. MISSING

Four cases where it is not instant

  1. The dead cluster goes silent instead of refusing: the client waits for its connect time limit first. No library sets one today. PROPOSED · jaeda, how.md MISSING · GAPS G10
  2. Writes: a retry is only safe when the write carries a unique key. PROPOSED · jaeda, how.md
  3. Only one cluster behind the name: no second address; the rescue router takes over, which is slower (seconds). PROPOSED · jaeda, how.md
  4. Callers we do not control: no rescue router; they wait for DNS, 30 to 60 s, unless they retry. PROPOSED · jaeda, how.md

jaeda's position on G10 client library PROPOSED · jaeda, POSITIONS.md

every client library sets a connect time limit (2 to 3 s), retries once on another address when the request is safe, and ships the rescue router. Add the missing DoD rows.

  • "Gossip"? Not strictly: apps do not talk to each other. It is "address books that list where the address books are". PROPOSED · jaeda, how.md

6 · Frontend failover

Web frontends and their standby

Web runs on Cloudflare Workers. A copy of every release waits on Vercel. If Workers break, the web name is repointed to Vercel.

  • Who flips it, and how, is not written anywhere. MISSING OPEN · OD-25
  • If the Cloudflare zone dies, web users are lost until it is back. This is accepted. DECIDED · ARCH:332-334
  • Rollout 10 → 50 → 100 % tested on real Workers. TESTED · spike C:45-49

Release of a web frontend

Release tin's web frontend 1.4.0

CICI: upload taggedWorkerCICI: deploy VercelcopyCICI: primordial chart withCloudflareDeployKargoKargo writes pichustageArgoCDArgoCD: record onPrimordialT3T3 cf-deploy: secrets, 10 → 50→ 100 %KargoHealthy → Kargo next stage

  1. CI CI uploads a tagged Worker version (wrangler versions upload --tag). It never deploys. DECIDED · ARCH:739-741; T3:3280
  2. CI CI publishes tin's primordial chart with a CloudflareDeploy naming the tag. DECIDED · ARCH:741-742
  3. CI CI also builds and deploys the Vercel standby copy. DECIDED · nextjs:180-184
  4. Kargo Kargo sees the release and writes it into the pichu stage file (unique tag ci-<git sha>). DECIDED · ARCH:742; T3:3340-3342
  5. ArgoCD ArgoCD puts the CloudflareDeploy record on Primordial. DECIDED · portal:114-117
  6. T3 T3's cf-deploy finds the version by tag, adds the secrets itself and rolls out with Cloudflare's own % split. Tested: 20 / 95 / 200 of 200 requests on the new version. DECIDED · T3:3274-3325 TESTED · spike C:45-49
  7. Kargo T3 reports Healthy only when the rollout is complete; Kargo moves on to pikachu. Rollback = promote an older version. DECIDED · T3:3352-3353; ARCH:742-744
  • One production path: OpenNext → Cloudflare Workers. Vercel is only a standby, never a second production path. DECIDED · nextjs:39, 43-44
  • First ever deploy: the chart creates the Worker once with a "not deployed yet" placeholder, because Cloudflare's first upload goes live at 100 %. DECIDED · T3:3296-3299 TESTED · hole H1, spike C:90
  • The landscape is chosen at runtime by a Worker setting, never guessed by the browser. DECIDED · nextjs:328-332
  • The sign-in address (issuer) is built in at build time; a different landscape issuer needs a new build. DECIDED · nextjs:266-268, 332-335
    • That clashes with "one build promotes through all stages" (gap G8). OPEN · decision 27
  • In-cluster frontend chart: only in the six dev/test/preview profiles, off by default. Never production. DECIDED · frontend-standalone-chart.md:57-61

The standby flip

ClientBrowser types tin'sweb namePersonA person or a checksees Workers brokenDNSCNAME (needs Cloudflare DNSeditable)VendorCloudflare Worker(normal)VendorVercel copy (afterflip)edit CNAME

What exists in the design: DECIDED · nextjs:180-184; ARCH:329-331

  • CI deploys every release to both Cloudflare and Vercel.
  • Vercel has the domain and certificates set up in advance.
  • A weekly test request checks the Vercel copy works.
  • Failover = repoint the CNAME (a DNS alias) to Vercel.

What is not written anywhere: MISSING

  • Who or what flips the CNAME, and what triggers it.
  • Who owns the weekly test: open owner decision OD-25, default a production-operations node under T3. OPEN · nextjs:413-415
  • How the Vercel build gets its landscape settings and secrets.
  • What replaces the Cloudflare-only parts on Vercel: R2 page cache, Durable Object queue, D1 tag cache, Cloudflare Images.

Proposed defaults: PROPOSED · this chapter, decisions 24-26

  • A person flips it with a one-command runbook after the weekly test is green; no automatic flip in v1.
  • Web name web.<service>.<platform>.<landscape>.cluster.atomi.cloud, written by the traffic controller.
  • A second "standby" build for Vercel, page cache off.

What is lost when something dies

What diesWeb (browser)Phone app (Flutter)Source
One clusternothing; the backend name drops the dead clustersameDECIDED · chapter 2
Cloudflare Workers onlyflip the CNAME to Vercel; down until flippedAPI calls fine (the API is not on Workers)DECIDED · ARCH:329-332
Cloudflare zone (atomi.cloud)lost, acceptedthe rescue router moves to the spare base nameDECIDED · ARCH:332-334, 353-360
All of Cloudflarelost; error portal lost (accepted)rescue router via the AWS copy of the address bookDECIDED · ARCH:336-339, 350-352
Vercel toonothing named as a third host—MISSING
  • Phone apps in a Cloudflare zone death: API calls move to the spare name, but sign-in cannot follow (the issuer is baked). PROPOSED · reading, this chapter

A browser's first request

  • A browser's first request has no rescue router: the page that holds it has not loaded yet. If DNS cannot answer, nothing we ship can help. DECIDED · ARCH:333-334
  • So for browsers only DNS fixes help: the CNAME flip, or the zone coming back.
  • DNS changes reach users within about TTL + 3 s (worst 57 s on 8.8.8.8 with TTL 60). Measured on A records, not a CNAME flip. TESTED · spike D:93-102
  • A name looked up before it exists is cached as "does not exist" for 30 minutes, so the Vercel name must exist before any flip. TESTED · spike D:106-107

Server side, browser side, phone

  • nextjs server: rescue router off; it retries once on a network error; its rescue is a redeploy. What "redeploy" means in practice is not written. DECIDED · nextjs:165-168; bfu:83-87 MISSING
  • Browser: rescue router on; reads the saved Doc C; checks the allowlist; offline page from a service worker. DECIDED · nextjs:165-166; frontend-ux-doctrine.md:119-120
  • Flutter: each landscape is its own store app; issuer, sign-in page and key addresses are built in; the router keeps the last good book forever. DECIDED · flutter-base.md:362-369; dart-api-engine.md:57-71
  • The spare base name (S8) gives phone apps a separate built-in root if atomi.cloud dies. DECIDED · ARCH:326-327

Error catalogs on the edge

  • Problem catalog = each service's list of known errors, with a "can retry" flag. The UI uses it: retry, stay, or show a full error. DECIDED · frontend-ux-doctrine.md:112-118
  • T3 publishes it as a static file next to the address books, 60 s cache; frontends never call Primordial for it. DECIDED · ARCH:284-287; portal:327-331
  • Error portal (erbium) = a human-readable site of those errors, on Cloudflare only. If Cloudflare dies it is gone; accepted. DECIDED · ARCH:338-339
  • Unit: per service (older) or per platform × tier (newer). OPEN · decision 28

Everything else in this chapter

What can go wrong, and what happens then
What goes wrongWhat happensWho actsSource
Bad Worker coderefused at uploadCI failsTESTED · spike C:60-67
Rollout error at Cloudflarecontroller stops in Degraded, no looppersonTESTED · spike C:60-67
Secret change then rollbackCloudflare refuses (10220); works with forceT3 uses version id by defaultTESTED · spike G3:124 DECIDED · T3:3313
CI deploys straight to productiona policy check turns redCIDECIDED · nextjs:99
Cloudflare Workers downweb down until the CNAME is flipped to Vercelnot named (OD-25 is about the probe)DECIDED · design, MISSING who
Vercel copy silently brokenweekly test should catch it; owner not chosenOD-25OPEN
Cloudflare zone deadweb users lost; phone apps move to spare name; sign-in cannot follow—DECIDED · accepted
Error portal downportal gone; apps keep classifying errors from the edge file—DECIDED
Tested and not tested
ItemStateSource
Upload tagged version, roll out 10 → 50 → 100 %TESTED · : 20 / 95 / 200 of 200 requests on new versionspike C:45-49
T3 adds Worker secrets, CI never sees themTESTED · : secret value returned 0 timesspike G3:113-116
Minimum Cloudflare tokenpartly: "Workers Scripts: Edit" observed; least-privilege not testedspike C:11; MASTER:696
Custom domains and routes for WorkersNOT TESTED · workers.dev onlyspike C:111
CNAME flip to VercelNOT TESTED—
Dual deploy to Vercel, weekly testNOT TESTED · dry-run row in the DoD onlynextjs:412
Browser rescue routerNOT BUILT · NOT TESTED—
How far back rollback can reach (version retention)NOT TESTEDspike C:108-112
Open decisions in this chapter
#QuestionDefaultWhose
F-1Who owns the weekly Vercel test and the switch to Vercel (OD-25)A: a production-operations node under T3corpus default (nextjs:414)
F-2Who flips the CNAME, and howa person, by a one-command runbook, after the weekly test is green; no automatic flip in v1PROPOSED · this chapter
F-3Exact web name and zone for a frontendweb.<service>.<platform>.<landscape>.cluster.atomi.cloud, written by the traffic controller like other namesPROPOSED · this chapter
F-4Vercel build: same build or a second build, and what replaces R2/D1/DO/Imagesa second build with a "standby" setting; page cache off on VercelPROPOSED · this chapter
F-5One build for all stages vs one build per issuerone build per landscape issuer (as nextjs:332-335 already says)PROPOSED · reading
F-6Catalog file unit: per service or per platform × tierper platform × tier (newest text)DECIDED · by newer text; confirm
F-7Error portal template adoption (OD-15) and its hold (OD-11)not ruled; blocks portal rowsOPEN · corpus
F-8Sign-in when the Cloudflare zone is deadsee chapter 4OPEN

7 · Sign-in (Logto)

Sign-in with Logto

One Logto per landscape, at one fixed address built into every app. A cluster dying does not affect sign-in.

  • One self-hosted Logto cannot be one issuer on two domains. DECIDED · Logto's source, v1.46.0
  • Tested on real Logto 1.46.0: a refresh sent to a spare address works, and the new tokens keep the same issuer. TESTED · LOGTO-MULTIDOMAIN.md
  • Settle first: is production one "serving" landscape, or four? OPEN · GAPS G1

How it is deployed

How a landscape's Logto starts

T3Bootstrap: operator login intoInfisicalLogto pods start and pull itFork patch: create that loginat bootT3logto-operator signs inT3Creates apps, APIs,settingsT3Writes app secretsto Infisical once

  1. T3 Bootstrap writes the operator's own machine login (M2M) into Infisical. DECIDED · alog:259-264
  2. Logto's chart pulls it. If it is missing, Logto refuses to start, on purpose. DECIDED · alog:330-332
  3. At every boot, our fork patch creates or repairs that machine app in Logto's database (Logto's API never lets us choose a secret). DECIDED · alog:303-306
  4. T3 logto-operator signs in with it. It is the only thing that changes objects inside Logto. DECIDED · ARCH:406
  5. T3 It creates every app (LogtoApp), API (LogtoResource) and setting (LogtoInstance), and writes each app's id and secret to Infisical. Once written, never regenerated. DECIDED · ARCH:411-414
ChartGoes toHoldsSource
App charteach cluster of the landscapeLogto pods; the issuer address; database login and boot secret from InfisicalDECIDED · alog:316-332
Primordial chartPrimordial, oncethe landscape's database request; one boot credential per platform; the LogtoInstance recordDECIDED · alog:366-371; lop:683-687
  • Our code: a small fork of Logto (aldehyde.logto: one boot patch and a "sanitize preview copy" command) and logto-operator. DECIDED · alog:20-22, 85-88
  • Database: one per landscape, an ordinary T3 Database (Neon in prod by default). Moving it never changes the issuer. DECIDED · T3:675-676, 2248-2249
  • Upgrades: one landscape at a time, by Kargo. Test 1.45 → 1.46 with 3 copies: tokens, refresh, machine logins, keys 0 failures; interactive sign-ins 0–2 failed per roll. DECIDED · lop:699-702 TESTED · spike E:11
  • Signing keys live in the database, so every copy has the same keys. TESTED · spike E:78
  • Dev and preview: each instance has its own Logto and database. Preview copies of prod data must have every machine secret replaced first; that guard is not built yet. DECIDED · alog:136-137 OPEN · env:1486-1489
  • No Logto version is pinned; default proposal v1.46.0. MISSING OPEN · decision 32

The issuer address

  • Issuer = the exact address that signs tokens. Every token says "issued by <this>", and every service checks it.
  • Fleet: api.lithium.aldehyde.<landscape>.cluster.atomi.cloud, made from the landscape, never typed in. DECIDED · alog:99-100; lib-contracts:150
    • Example: api.lithium.aldehyde.raichu.cluster.atomi.cloud.
  • Permanent once live: changing it makes every token unverifiable. DECIDED · alog:102-105
  • Built into apps; never from a file, setting or request. Apps carry a fixed landscape → issuer table. DECIDED · lib-contracts:160-169
  • No mew-style name for Logto. DECIDED · alog:374-377

jaeda's position on G1 landscapes PROPOSED · jaeda, POSITIONS.md

pichu, pikachu, raichu, ampharos are FOUR separate landscapes, one sign-in issuer each. "serving" is the name of the group (the class of landscapes that run apps for users), not a landscape. Why: ARCHITECTURE, fleet.md, the newer T3 goal draft and every owner walk-through (D1: a landscape has ONE region) agree; one landscape cannot have two regions. The logto-operator / helm-wrapper text that folds them into one serving landscape is the stale one. Names: <svc>.<platform>.<landscape>.cluster.<base>.

Tokens and lifetimes

TokenLifetimeSource
Access token (the short pass)10 minutesDECIDED · ARCH:423
Refresh token (the long key)14 days, a new one at every useDECIDED · ARCH:424-425
Magic-link one-time token120 sDECIDED · deferred-login.md:170
Logto's own default access token1 hour, unless the API sets its ownDECIDED · logto@v1.46.0 init.ts:581
  • So 10 minutes holds only if logto-operator writes it on every API. That is its job. DECIDED · lop:735-736
  • Services check tokens locally and never call Logto per request; they re-fetch keys on an unknown key id and keep the last good keys while Logto is down. DECIDED · env:484-485; T3:2361
  • Reuse of an old refresh token being caught is assumed, but Logto does not document it. OPEN · study I:155
  • Signing-key rotation policy: not written, not tested. MISSING NOT TESTED · spike E:93

When things fail

What failsSigned-in usersNew sign-insSource
A Logto pod restartsfine0–1 may fail with preStop setTESTED · spike E; T3:723
One cluster diesfine: same name, other cluster, same databasefinePROPOSED · (jaeda), how:205-206
Logto or its database downwork while their pass lastsstopDECIDED · env:1497-1499
Our DNS name is deadwork up to 10 min, then waitstopPROPOSED · (jaeda), how:208-220
The region dieswait for a restore (hours)stopDECIDED · ARCH:291-294

Sign-in when our DNS name is dead

ClientMain name dead; apps move tospare nameClientCalls with thecurrent pass: workNew sign-in, Google,passkeys: fail10 min: pass runs out; refreshfailsClientApp waits; refresh key lasts 14daysClientName back: refresh works again

  1. Client Our DNS name itself is dead. The rescue router moves API calls to a spare name. The issuer (sign-in address) is baked in and does not move. DECIDED · ARCH:252; lib-contracts:160
  2. Client People already signed in keep calling services: tokens are checked locally with cached keys. PROPOSED · jaeda, how:208-220 DECIDED · env:484-485
  3. New sign-ins fail. Google sign-in fails (callback must be registered). Passkeys fail (bound to the exact host). Cookies are per exact host. DECIDED · logto@v1.46.0
  4. After 10 minutes the pass runs out. Today Logto has no spare address, so getting a new one fails. The lab showed a refresh over a spare address would work (option iii). DECIDED · ARCH:423 TESTED · LOGTO-MULTIDOMAIN.md
  5. Client The app waits; the user is not logged out, because the refresh key lasts 14 days. DECIDED · ARCH:424-425 PROPOSED · jaeda, how:208-220
  6. Client When the name is back, the quiet refresh works and the user carries on. PROPOSED · jaeda, how:208-220

Can one Logto answer on two domains?

The sign-in standard (OIDC): one issuer = one exact address string. Several network addresses can reach it, but the string never changes. DECIDED · OpenID Discovery §4.3, Core §3.1.3.7, RFC 9207

What Logto's own code says (v1.46.0)

What OIDC (the sign-in standard) allows

(Spec rules, written from memory by the reader agent – check the links before relying on wording.)

  • The issuer in the discovery file must equal the address the client expected. (OpenID Discovery 1.0 §4.3)
  • The token's iss must exactly match the issuer. (OpenID Core §3.1.3.7)
  • RFC 9207: the sign-in reply carries iss; the client compares it. (mix-up defence)
  • Passkeys work only under the host (or parent domain) they were made on. (WebAuthn rpId)
  • So: one issuer = one exact address string. Several network addresses can reach it, but the string never changes.

What Logto (open source, v1.46.0) does

QuestionAnswerSource
What sets the issuer?ENDPOINT + /oidc, read once at startlogto@v1.46.0:packages/core/src/env-set/index.ts:109-113; shared/src/node/env/UrlSet.ts:21-22
Does the issuer follow the request's host?No on a normal (single-tenant) install: every request goes to the default tenant firstlogto@v1.46.0:packages/core/src/utils/tenant.ts:167-172; env-set/utils.ts:22-23
Custom domains?only in multi-tenant mode (* in ENDPOINT) + Cloudflare hostname setup (Logto Cloud)logto@v1.46.0:packages/core/src/app/init.ts:66-68; libraries/domain.ts:70-72
More than one domain per tenant?flag "only available to enterprise customers running private instances"; max 10logto@v1.46.0:packages/shared/src/node/env/GlobalValues.ts:262
With custom domains, is it one issuer?No: each (tenant, domain) pair gets its own issuerlogto@v1.46.0:packages/core/src/tenants/index.ts:120; Logto Cloud docs custom-domain.mdx:139
Behind a proxy?TRUST_PROXY_HEADER=1 trusts forwarded host/protologto@v1.46.0:packages/core/src/main.ts:25; Logto OSS docs
Discovery file on another host?issuer stays fixed; endpoint links (token, keys) follow the request hostnode-oidc-provider (upstream, not Logto's fork): lib/helpers/oidc_context.js:61-62
  • Reader's inference, not run: on our install, asking the spare name for a token should return iss = <main issuer>, with token/key links on the spare name. That is exactly what Brief 2's test checks. PROPOSED · reader agent

Options for the rare "our DNS name is dead" case

OptionKeeps workingNeedsCost / risk
A. Do nothingsigned-in users 10 min; then waitnothingnobody new signs in during the outage
B. Longer access tokens (e.g. 2–8 h)most signed-in users ride it outone operator settinga stolen pass lives hours; revoke is slower
C. Refresh over the spare address, issuer unchangedbackground refresh + machine tokensclient libraries learn a baked spare token/keys address; cert on spare name; a testno browser sign-in, Google sign-in, passkeys or cookies on the spare
D. Issuer on its own domain at a different DNS hosteverything, as long as that host livesa new DNS host + registrar for the issuer name only; issuer change = one-time re-login for everyoneone more DNS bill; must be decided before go-live (issuer is permanent)
E. Logto answers as two issuerseverything on both namesa fork patch or Logto Cloud/enterprise; every service accepts two issuersbreaks the "one issuer" rule; two passkey sets; bigger attack surface
F. Same issuer name served by two DNS hostseverythingthe DNS fix in PROPOSAL-DNS.md (two hosts for the same zone)depends on Brief 2's DNS design
  • Options A, B, C are jaeda's 1–3 (how:221-224). D, E, F come from the reader agent's list PROPOSED. None is decided. OPEN
  • Recommended default PROPOSED · this chapter: F if the DNS proposal adopts two hosts for the same zone (fixes everything, no app change); otherwise C + keep A's behaviour. Avoid E. Decide D before go-live or never, because the issuer can never change after.

TEST RESULT – to be filled from model/proposal/LOGTO-MULTIDOMAIN.md (Brief 2: real Logto in docker behind two host names A and B; discovery, keys, machine token, browser sign-in, refresh on B with iss = A, userinfo, sign-out, cookies, social callback, passkey rpId). Status when this chapter was written: not yet available.

The options for a dead name

OptionKeeps workingNeedsCost or risk
A. Do nothingsigned-in users 10 min; then waitnothingnobody new signs in
B. Longer passes (2–8 h)most signed-in users ride it outone settinga stolen pass lives hours
C. Refresh over the spare address, issuer unchangedbackground refresh, machine tokenslibraries learn a baked spare address; a testno browser sign-in, Google, passkeys, cookies on the spare
D. Issuer on its own domain at another DNS hosteverything, while that host livesa new DNS host for the issuer name onlydecide before go-live: the issuer is permanent
E. Logto answers as two issuerseverything on both namesa fork patch or Logto Cloudbreaks "one issuer"; two passkey sets
F. Same issuer name served by two DNS hostseverythingthe DNS fix in the proposaldepends on the DNS proposal
  • None is decided. OPEN · decision 31
  • Proposed default: F if the DNS proposal adopts two hosts for the same zone; otherwise C, keeping A's behaviour. Avoid E. PROPOSED · this chapter
  • jaeda's position (G5): accept A for v1 and put the issuer name on a base the DNS proposal makes survive one host outage. Do not run two issuers. PROPOSED · jaeda, G5

Test result: Logto on two names

  • Recommendation: option (iii) = a spare address for Logto under the spare base name, the issuer string unchanged, libraries fall back to it. Tokens stay at 10 minutes. This is option C above. PROPOSED · levi, LOGTO-MULTIDOMAIN.md
  • Today the design has no spare name for Logto, so if the issuer's name dies: signed-in users work up to 10 minutes, then wait; nobody new signs in. TESTED · LOGTO-MULTIDOMAIN.md

Copied from model/proposal/LOGTO-MULTIDOMAIN.md as it stood at 2026-11-12 07:53 UTC; a rebuild picks up later changes.

Answer first

  • Logto has one issuer name, but it answers on any address. TESTED · Logto 1.46.0
    • Example: issuer A, reached via name B. Discovery on B still says issuer A; its endpoint links point at B.
  • The key test passed. A was down. The app sent its refresh to B. Logto answered, and the new tokens say iss = A. A check expecting A passed, with keys fetched from B. TESTED · LAB/failover.json
  • So "sign-in over the spare address, same issuer name" works. Sign-in with password on B works too, tokens say A.
  • What does break on B:
    • Browser sessions: cookies belong to A only, so a user must sign in again once on B TESTED · : login_required.
    • Passkeys: Logto ties a passkey to the host name it was made on (SOURCE). A passkey made on A does not work on B.
    • "Sign in with Google" and the like: Logto sends the provider B's callback (SOURCE). It works only if B's callback is registered with the provider beforehand.
  • Never change Logto's ENDPOINT to B. Then every new token says iss = B, and every app expecting A fails: unexpected "iss" claim value TESTED.
  • Today's design has no spare name for Logto (no DNS record, route or certificate). So right now, if the issuer's name dies: signed-in users work up to 10 minutes, then wait; nobody new signs in.
  • Recommendation PROPOSED · levi: option (iii). Give Logto a spare address under the spare base. Keep the issuer string A. Teach the client and service libraries to fall back to B for token, sign-in, userinfo and keys. Keep tokens at 10 minutes.
  • When this matters: only if the issuer's DNS chain dies (Cloudflare zone, .cloud, Cloudflare account). With option 4 of PROPOSAL-DNS.md, a dead Route 53 or Google does not touch it.
About this paper: who wrote it, tags, short names

Written 2026-11-12 by levi (helper of jaeda) for the owner. Tags: DECIDED (owner or corpus, cited) · TESTED (we ran it) · DOCS/SOURCE (Logto docs or code) · PROPOSED (levi's idea unless marked) · OPEN (owner must choose). Short names: C/ = corpus copy proposal/corpus/; LAB/ = proposal/lab-logto/ (real Logto; built by helper annis, last tests and clean-up by levi). "Issuer" = the sign-in server's official name. It is written into every token (iss), and every service checks it. Name A = the issuer's real name. Name B = a spare name for the same Logto.

1. What the corpus decides (DECIDED)
  • One Logto issuer per landscape, shared by every platform there (C/goals/aldehyde-logto.md:92-98; C/concepts/ARCHITECTURE.md:365-376).
  • Its name is fixed by convention: api.lithium.aldehyde.<landscape>.cluster.atomi.cloud (C/goals/aldehyde-logto.md:99-104).
    • Example: api.lithium.aldehyde.ampharos.cluster.atomi.cloud.
    • "OIDC-permanent": changing it makes every token unverifiable (same lines).
  • Apps get the issuer baked in at build. It is never read from the address books (edge docs), so a bad address book cannot steal sign-ins (C/concepts/ARCHITECTURE.md:251-255).
  • Services check only their own landscape's issuer: no cross-issuer checks, no second issuer (C/concepts/ARCHITECTURE.md:372-374).
  • Every caller uses the public name, even inside the cluster ("privatePath: unsupported"). Reason given: "issuer, discovery, cookies, and passkeys are name-bound" (C/concepts/ARCHITECTURE.md:401-405).
  • Token lifetimes: access token 10 minutes, refresh token 14 days, rotating, apps get a fresh token on open (C/concepts/ARCHITECTURE.md:423-427). The operator sets these per API resource and app (C/goals/logto-operator.md:735-739).
  • Logto's own default is 1 hour TESTED · : expires_in: 3600 in LAB/flows.json. So the 10 minutes only holds once the operator applies its policy.
  • Honest rule already in the corpus: if every issuer host is down, sign-in and renewal stop, with no time bound (C/concepts/environments.md:1497-1499).

2. What Logto does on a second name TESTED · unless marked

Setup: Logto OSS 1.46.0 + Postgres 17, behind nginx with a local CA. ENDPOINT=https://auth-a.lab.test. Same Logto under A (auth-a.lab.test) and B (auth-b.lab.test). Full detail: LAB/REPORT.md.

TestOn AOn BNote
Discovery issuerAAendpoint links follow the host (B)
Signing keys (JWKS)samesame–
Machine token (M2M)iss Aiss A–
Password sign-in, browserworksworks, iss Acheck "expect A" PASS
Refresh / userinfo200200–
CookiesA onlyB onlyA's cookies on B: login_required
A down, refresh at B–200, iss A, PASSnew refresh token issued
ENDPOINT changed to B–new tokens iss Bapps expecting A: FAIL
Old A tokens after that changestill valid as A–same keys
Passkey site idhost Ahost BSOURCE web-authn-verification.ts:313-317
Social callbackA's URLB's URLSOURCE use-social.ts:259
Default token life1 hour1 hourthe operator sets 10 min (part 1)

Logto facts behind this (SOURCE/TESTED):

  • ENDPOINT sets the one issuer ENDPOINT/oidc; it does not follow the request host TESTED · : B kept saying A; after restart with B, both names said B.
  • Endpoint links in discovery follow the request host TESTED. That is why a client can talk to B and still get tokens for A.
  • One Logto = one issuer. Logto Cloud has a "custom domains" feature; whether it gives several issuers: NOT CHECKED today.
3. Options for "our DNS name is dead"

When does this happen? Only when the issuer's DNS chain dies: Cloudflare's zone, the .cloud TLD, or the Cloudflare account (see PROPOSAL-DNS.md). With option 4 of that proposal, Route 53 or Google dying does not kill the issuer name.

OptionUsers in the "name dead" caseNeedsSecurity cost
(i) do nothingwork ≤10 min, then stuck; no new sign-insnothingnone
(ii) longer tokens (e.g. 4 h)work ≤4 h; no new sign-insoperator token policystolen token lives 4 h
(iii) spare address, same issuerkeep working; new password sign-ins worksee belowsmall
(iv) issuer on its own domaindepends on that domain's chainnew issuer name before go-livenone new
(v) two issuers–two Logtos or apps trusting twobreaks a corpus rule

(i) Do nothing.

  • Access tokens last 10 minutes DECIDED. Refresh tokens last 14 days, so nobody is logged out; they just wait.
  • Hidden extra: services fetch Logto's keys from A too. If their key cache runs out while A is dead, every check fails, even for valid tokens. Fix in any option: cache keys for a day.

(ii) Longer access tokens.

  • Example: 4 hours. Most outages pass unseen by signed-in users.
  • Cost: a stolen token, or a removed user, keeps working up to 4 hours. The corpus already counts token life as the delay before a revoke bites (C/concepts/environments.md:1665) and caps it at 24 h (C/concepts/environments.md:1548).
  • Needs only the operator's token policy (C/goals/logto-operator.md:735-739). Does nothing for new sign-ins.

(iii) Spare address, issuer unchanged (RECOMMENDED). TESTED in the lab.

  • T3 / gateways:
    • DNS record api.lithium.aldehyde.<l>.cluster.<spare> written by the traffic controller (same IPs as A).
    • Gateway route + TLS certificate for that name (today missing: part 4, gap 15).
  • Client library (bun + dart auth engines):
    • Baked list of auth addresses: A first, B second. B is derived from the baked spare root, never from an address book.
    • On a hard connection failure to A: send token, sign-in, userinfo and key requests to B.
    • Always check iss = A (baked). Never accept another issuer.
  • Services: fetch keys from A, else B; cache them a day.
  • Logto config: register B's callback at every social provider; nothing else (redirect URIs are the apps' own).
  • What users see on B: native apps keep going; a fresh sign-in needs password or email code. Passkeys do not work on B. Browser sessions need one new sign-in.
  • Security cost: the spare domain becomes part of sign-in. Someone who takes it over could show a fake sign-in page. Guard it like the main one: registrar lock, 2FA, CAA records.

(iv) Issuer on its own domain, at another DNS host.

  • It spreads the risk; it does not remove it: the issuer then dies with that domain.
  • With (iii) in place, the issuer string's own DNS matters only for the main path, so (iv) adds little.
  • It is a change to a DECIDED naming rule (C/goals/aldehyde-logto.md:99-104), possible only before go-live. OPEN, not recommended.

(v) Logto answering as two issuers.

  • Logto OSS has one ENDPOINT, so one issuer TESTED.
  • Two issuers would need two Logtos or apps that trust both. The corpus forbids cross-issuer checks (C/concepts/ARCHITECTURE.md:372-374). Rejected.

(vi) Found along the way PROPOSED:

  • Cache signing keys for a day in every service (keys did not change even when ENDPOINT changed: TESTED).
  • Pre-register B's social callbacks now; it costs nothing.
  • Passkeys cannot follow the host in Logto 1.46 (rpId = request host, SOURCE). Keep a second factor (password or email code) available for passkey users.
4. How Logto is deployed today (per the corpus, 15 lines)
  1. Our fork AtomiCloud/aldehyde.logto = upstream Logto OSS at a pinned tag + one small boot-seed patch + a sanitizer (C/goals/aldehyde-logto.md:425-428).
  2. Published as image ghcr.io/protodurian/aldehyde.logto + an app chart and a primordial chart (C/goals/aldehyde-logto.md:4-5).
  3. One issuer and one user database per landscape, shared by every platform (C/goals/aldehyde-logto.md:92-98).
  4. Sign-in and the Management API share one host: api.lithium.aldehyde.<landscape>.cluster.atomi.cloud, API on /api (C/goals/aldehyde-logto.md:99-104, 338-342).
  5. Database URL comes from that landscape's database binding through an ExternalSecret (C/goals/aldehyde-logto.md:320-326).
  6. Boot-seed: on every start, Logto upserts the operator's machine login from Infisical (/lithium), so a restored database heals itself (C/goals/aldehyde-logto.md:248-268).
  7. logto-operator (separate, on Primordial) is a pure API client: LogtoApp CRs become apps and API resources; secrets go to Infisical (C/concepts/ARCHITECTURE.md:406-414).
  8. Redirect URIs are derived from the landscape's live names, never typed by hand (C/goals/logto-operator.md:604-610).
  9. Exposure: the normal LB Service + gateway route; no multi-landscape name for Logto (C/goals/aldehyde-logto.md:338-342, 374-377).
  10. Token policy 600 s / 14 days rotating, set by the operator (C/goals/logto-operator.md:735-739).
  11. Garden (dev/preview): one Logto per instance, issuer api.lithium.<platform>.<instance>.<landscape>.<zone>, local database; castform uses a sanitized Neon fork (C/goals/aldehyde-logto.md:121-205).
  12. Connectors (Google sign-in etc.): none in Garden; the live fleet list is UNSPECIFIED (C/goals/logto-operator.md:140-146; C/concepts/environments.md:1664).

Gaps I noticed:

  1. The Logto version to pin is not stated (C/goals/aldehyde-logto.md:425 says only "pinned to a tracked ref"). The lab used 1.46.0.
  2. Replica count and how many clusters run Logto per landscape: UNSPECIFIED (C/goals/aldehyde-logto.md:117-121, 357-371).
  3. No spare name for Logto exists in the design. The spare base (S8) gets "the SECOND base domain's wildcard" certificate (C/goals/charts/kgateway.md:149-154); its exact names are not stated. A TLS wildcard covers one label only (stated for dev hosts at C/goals/charts/kgateway.md:93-94; general TLS rule). So *.<l>.cluster.<spare> would not cover api.lithium.aldehyde.<l>.cluster.<spare>. No gateway route for a spare Logto name is stated either.
5. Not tested
  • Sign-out (end_session) on A and B.
  • A real social provider round trip, and a real passkey made with a virtual authenticator (facts above are from Logto's source).
  • Refresh-token rotation and reuse detection (the lab app had rotation off).
  • The operator's 10-minute policy (the lab used Logto's 1-hour default).
  • A real client library falling back from A to B; real DNS; the fork aldehyde.logto (the lab used upstream 1.46.0).
  • Logto Cloud custom domains.

Machines (M2M)

  • A machine gets its token from the target landscape's Logto and asks for the target service's audience. DECIDED · ARCH:376-377; env:1506-1507
    • Example: a nightly job calling tin in raichu signs in at raichu's Logto with audience "tin in raichu".
  • Only the landscape picks the issuer, never the platform. DECIDED · lop:177-178

Everything else in this chapter

What can go wrong, and what happens then
What goes wrongWhat happensWho actsSource
Boot secret missingLogto refuses to start (on purpose)person fixes InfisicalDECIDED · alog:330-332
Operator regenerates a secretforbidden: Infisical is the commit point—DECIDED · ARCH:411
10-min rule not written on an APIthat API's tokens live 1 h (Logto default)operator testDECIDED · Logto code
Preview copy of prod datamust replace every machine secret first; guard not built—OPEN · R0
Pod roll during sign-in0–1 failed sign-in; clients restart the flowclientTESTED
DNS name deadsee 4.4—PROPOSED
Signing keys rotatedimmediate by default; tokens signed with the old key failpolicy not writtenMISSING
Wrong meaning of "landscape" builtDNS names, issuers and audiences all differ—OPEN · GAPS #1
Tested and not tested
ItemStateSource
Rolling upgrade 1.45 → 1.46, 3 copiesTESTEDspike E
Same keys on every copyTESTEDspike E:78
Role/claim changes apply at refresh (1.41)TESTEDenv:391-392
Preview callback missing → HTTP 400 invalid_redirect_uriTESTEDgoals/ci-preview-vcluster-lane.md:590-592
Logto's issuer behaviour on a second host nameread in code; NOT RUNlogto@v1.46.0
Refresh over a spare nameNOT TESTED · Brief 2 is testinghow:225
Refresh-token reuse detectionNOT TESTEDstudy I:155
Key and cookie-key rotationNOT TESTEDspike E:93
Logto in two regions, several copies (owner D37)NOT TESTEDOWNER-DECISIONS D37
Open decisions in this chapter
#QuestionDefaultWhose
L-1Is prod one serving landscape (one issuer) or raichu + ampharos (two issuers)?two issuers, as ARCH §5 (matches home-sharding)PROPOSED · this chapter
L-2Spare sign-in path (options A–F)F if the DNS proposal allows, else CPROPOSED · this chapter
L-3Logto version pinv1.46.0reader agent
L-4Passkeys on?off in v1reader agent
L-5Machine token lifetime10 min, cached to expiryreader agent; study I suggests longer
L-6Signing-key rotationstaged, with a grace period ≥ access token lifePROPOSED · this chapter
L-7Social callback addresses to registerone per landscape issuer, listed in LogtoInstancePROPOSED · this chapter
L-8ENDPOINT, ADMIN_ENDPOINT, TRUST_PROXY_HEADER values; is the admin console exposed?ENDPOINT = issuer host; proxy header on; admin console not publicPROPOSED · this chapter
L-9Where prod Logto's database lives and whether it has a region copyNeon Scale in the landscape's region; no copy (v1)corpus default (ARCH:82; T3)

8 · Global and many regions

Many regions

Each user has one home landscape. Their data and their sign-in live there. A dead region is a restore, not a failover.

  • Only mercury's webhook name spans regions today. DECIDED · onboard:351
  • A raichu token is refused by ampharos. DECIDED · lib-contracts:158
  • No cross-region move has been run on real vendors. NOT TESTED · spike M:4

Landscapes and virtual landscapes

Landscape (raichu)Virtual landscape (mew)
Has clustersyes (raichu-opal, raichu-ruby)no
Has its own datayes, per platformno
Has its own sign-in (Logto)yesno
Isa placea name over places
  • mew = the production name over raichu + ampharos. celebi = the staging name; its hosts are only inferred ([pikachu]). DECIDED · fleet.md:231 OPEN · fleet.md:234
  • The mew name answers with the addresses of every landscape row with serve: true. DECIDED · ARCH:224-225
  • Region = an airport code from an allow-list. A landscape's region never changes. DECIDED · owner D1; fleet.md:213
  • The codes for raichu and ampharos are not written. Proposed: SIN and IAD. MISSING PROPOSED · decision 47
  • jaeda's position (G1): pichu, pikachu, raichu, ampharos are four landscapes, one sign-in issuer each. PROPOSED · jaeda, G1

How a new user gets a home

Ana, a new user in Singapore, gets a home

ClientApp: home claim present?ClientRead Doc B: whichlandscapesClientGo to home landscapeClientPing healthy landscapes; pickraichuSign in at raichu LogtoClientOnboarding writeshome_landscape: raichunoyes

  1. Client The app checks for a home claim on the user. Ana has none yet. DECIDED · ARCH:415
  2. Client It reads Doc B (which landscapes nitroso runs in; names only). A landscape appears there only once it is healthy. DECIDED · lib-contracts:187; T3:2869, 3053
  3. Client It pings each healthy landscape; Ana (or the app) picks raichu. DECIDED · lib-contracts:187
  4. Ana signs in at raichu's sign-in page. Then GET /User/Me gives 404 (new user) and onboarding starts. DECIDED · ARCH:418-419
  5. Client Onboarding writes home_landscape: raichu into Ana's Logto record; Logto puts the claim into every new token. DECIDED · logto:407
  6. Client From now on the app calls only …raichu.cluster.atomi.cloud. Not covered: Ana on a new phone may pick ampharos and make a second account. DECIDED · ARCH:240-241 MISSING
  • Phones and browsers do the picking; server libraries just honour the claim. DECIDED · lib-contracts:199
  • "Home per platform" (one paper) vs one home_landscape key in each landscape's Logto (another). Proposed: one home per user. OPEN · decision 41
  • Re-homing a user is needed before a landscape is removed, but no mechanism exists. Proposed: a manual, audited runbook in v1. MISSING PROPOSED · decision 46

Where data lives

Logto + its own DBLogto + its own DBCache inside eachclusterCache inside eachclusterVendortin DB, KV, filesVendortin DB, KV, filesVendorBackups at another vendorraichu (Singapore)ampharos (US)
  • A landscape is its data. DECIDED · ARCH:660
  • Each platform's database, key-value store and files sit in the landscape's region by default. DECIDED · T3:1195
  • Each landscape has its own Logto with its own database. DECIDED · ARCH:396-400
  • Caches live inside each cluster and never cross regions. DECIDED · owner D16
  • No live copying between regions in v1. A dead region = restore from backup, then point the record at the new place: hours. DECIDED · T3:2649; ARCH:293
  • Backups land at a different vendor (hard rule). Recovery point: minutes; recovery time: hours. DECIDED · ARCH:664-666
  • A standby database at a second vendor: default no (about 2× database cost). OPEN · O3
ThingPer landscapeSpans landscapesSource
Logto (sign-in)yes, one per landscapenoDECIDED · ARCH:367-372
tin's database, KV, filesyesnoDECIDED · T3:1195
Cacheper clusternoDECIDED · owner D16
Mercury intake name—yes (…mew…)DECIDED · onboard:351
Mercury hot data (Upstash)yesnoDECIDED · mercury:186
Mercury long-lived data (one Neon)—yes, one database for mewDECIDED · mercury:176
Primordial (T3, ArgoCD, Kargo)—one for the fleetDECIDED · ARCH:121

How a user reaches home

  • The app holds one name, its home's, e.g. api.tin.nitroso.raichu.cluster.atomi.cloud. DECIDED · ARCH:240-241
  • Sign-in for raichu is api.lithium.aldehyde.raichu.cluster.atomi.cloud, from a built-in table. DECIDED · lib-contracts:150, 169
  • A raichu token is refused by ampharos. DECIDED · lib-contracts:158
    • Warning: One paper says the opposite: one "serving" landscape with one issuer and a raichu ↔ ampharos failover. This is gap G1. OPEN · lop:558-568
  • If raichu's region dies, the rescue router does not help (it changes addresses, not landscapes). Ana waits for the restore, or is re-homed. DECIDED · ARCH:254

Machines across regions

  • A machine gets its token from the target landscape's Logto. Example: a job calling tin in ampharos signs in at ampharos's Logto. DECIDED · env:1508; ARCH:376-378
  • Audiences are per landscape, never reused across landscapes. DECIDED · env:1600

Moving across regions

  • The owner wants cross-region moves. The Migration record is the default way. DECIDED · owner D8 OPEN · O2, default yes
  • Editing a "move field" (provider, account, region, major version) does nothing by itself: T3 says MigrationRequired and keeps serving the old one. DECIDED · T3:1231
  • Steps: build beside → copy → check → short write pause → switch → keep the old one 48 h → delete. DECIDED · T3:2000-2256
KindCross-region methodThe app feelsSource
Databaselive copy, write pause max 5 swrites wait onceDECIDED · T3:2030
Key-value (Upstash)write to both, then a two-step switchnoneDECIDED · T3:2162
Filescopy + change journal + verify, short write holdwrites wait onceDECIDED · T3:2165
Cachenot moved: a new empty cachecold cacheDECIDED · T3:2244
  • Measured: Postgres moves between Neon, PlanetScale and DigitalOcean, all in Singapore, with the operator in the US (217 ms away): writes waited 3.2 s, 0 failed. TESTED · spike M:4, 22
  • In one region it should be 0.1–0.3 s. PROPOSED · estimate, spike M
  • Measured since, in one region (Singapore): a live move works up to 100,000 writes a second, 0 failed, 0 lost, with the library. TESTED · spike L
Write pause during a live Postgres move, by write rate TESTED · spike L, spikes2/l-load/REPORT.md
1,000 writes/s76 ms
5,000 writes/s54 ms
20,000 writes/s75 ms
50,000 writes/s509 ms
100,000, 2 subscriptions writes/s3.2 s
100,000, 1 subscription writes/s6.1 s
100,000, 8 subscriptions writes/s8.1 s and 18.1 s

Bar length is on a log scale (each step is 10× longer). Every rate: 0 failed, 0 lost. Reads never paused.

The numbers as a table
Writes / sVerdictFailed / lostHold
1,000PROVEN0 / 076 ms
5,000PROVEN0 / 054 ms
20,000PROVEN0 / 075 ms
50,000PROVEN0 / 0509 ms
100,000, 2 subscriptionsPROVEN0 / 03.2 s
100,000, 1 subscriptionPROVEN0 / 06.1 s
100,000, 8 subscriptionsPROVEN0 / 08.1 s and 18.1 s
100,000, 4 subs, small batchesNOT REACHED–replication never caught up
  • The pause is set by how far the copy is behind when writes are held, not by the library. Up to about 50,000 writes a second the copy keeps up. TESTED · spike L
  • What breaks first is the caller's own time limit: a 5 s limit at 100,000 writes a second → 1.54M failed. TESTED · spike L
  • One Postgres took 300,000 writes a second when the library groups writes, about 20,000 one by one. TESTED · spike L
  • The operator must run near the database, but Primordial's region is not written. Proposed: a small fence helper per region. DECIDED · T3:1676 MISSING PROPOSED · decision 48
How a move with no errors works

"Zero downtime" here means zero errors, not zero waiting. Writes wait about 3 seconds once. Reads never stop.

  1. Build: the operator makes the new database at the new vendor. The app does not notice.
  2. Copy: the new database copies everything, then keeps following every change. The app still uses the old one. This can take minutes or hours.
  3. Check: the operator waits until the new one is fully caught up and the row counts match.
  4. Hold writes: the operator raises a flag. The library makes each write wait instead of fail. Reads carry on.
  5. Last drops: the final changes arrive at the new database (well under a second). ID counters are set.
  6. Switch: the operator puts the new address and login in the secret. The library reconnects. The flag drops. The waiting writes go through, on the new database.
  7. Safety net: the old database now follows the new one for 48 hours, so we can go back the same way. Then it is deleted.
  8. Steps 4 to 6 are the only moment the app feels: 3.2 to 3.8 seconds measured from far away; 0.1 to 0.3 expected in the same region.
  9. Why not exactly zero: two databases cannot both take writes for the same data at once. One has to stop before the other starts.
  10. Without the library, step 4 shows up as errors instead of a wait (92 failed in our test).

Put off past v1

  • Live copying between regions. DECIDED · T3:3641
  • A global load balancer: ruled no. DNS movement stays; 30–60 s lag accepted. DECIDED · owner D17
  • Cross-vendor backups are not put off: they are required. DECIDED · T3:3641

Everything else in this chapter

What can go wrong, and what happens then
What goes wrongWhat happensWho actsSource
One cluster in raichu dieschapter 2: address removed, retrytraffic controllerDECIDED
raichu's database vendor diesrestore at another vendor; hoursperson + T3DECIDED
raichu region diesraichu users wait for restore; mercury moves to ampharospersonDECIDED
User picks the wrong home on a new phonesecond account possiblenobodyMISSING
raichu token sent to ampharosrefused—DECIDED
Mercury retry lands in the other regionpossible double deliveryreceiver must ignore repeatsDECIDED
Saved events stuck in a dead region's queuedelivered after recovery, within 72 h—MISSING rule
Landscape removed with users still homedrefused in design; no re-home toolpersonMISSING
Tested and not tested
ItemStateSource
Postgres moves between vendors (same region, operator far away)TESTED · : 3.2 s write wait, 0 failedspike M
Key-value and file moves between vendorsTESTED · spikes K, T; jaeda's table how:98-103how.md
Real region-to-region moveNOT TESTEDspike M:4
Geoproximity on Route 53NOT TESTED · no AWS tokenhow:127
Home claim flow, Doc B, ping-pickNOT BUILT · NOT TESTED—
Mercury multi-region intake, 421 protocolNOT TESTED · design—
Logto in two regions with several copiesNOT TESTED · asked for by the owner's whole-system test (owner D37)OWNER-DECISIONS D37
Open decisions in this chapter
#QuestionDefaultWhose
O2Migration record for movesyesT3 default
O3Standby database at a second vendornoT3 default
G-1Home: one per user (per landscape Logto) or one per platform?one per user, per landscape Logto (matches the single key)PROPOSED · this chapter
G-2New device / returning user who picks the wrong homeafter sign-in, check the other landscapes' Logto for the same email and offer to switchPROPOSED · this chapter
G-3Which issuer for mercury's management API behind mewcaller uses its own landscape's issuer; mercury accepts each host landscape's issuer for its management audience onlyPROPOSED · this chapter (security review needed)
G-4Mercury's Neon regionraichu (Singapore)PROPOSED · this chapter
G-5Queued events in a dead regionthe surviving region's mercury reads the dead region's Upstash (it is a vendor service, likely still up)PROPOSED · this chapter
G-6Re-home toola manual, audited runbook in v1PROPOSED · this chapter
G-7Airport codes for raichu and ampharosSIN and IADPROPOSED · this chapter
G-8Where T3 runs for far-away databasesa small fence helper next to each region's databasePROPOSED · this chapter
G-9celebi's hosts[pikachu]corpus inference (fleet.md:234)

9 · Mercury (webhooks)

Mercury: webhook intake

One webhook intake for the whole fleet, on a name that sends each provider to the nearest healthy landscape.

  • It saves first, then answers 200, then asks every receiver: exactly one says 200, all others 421. DECIDED · mercury:213, 283-287
  • It retries for 72 h, then parks the event in a dead-letter queue. DECIDED · mercury:337-339
  • Multi-region intake and the 421 rule are not tested yet. NOT TESTED · design

Where it runs

  • Once for the whole fleet, as an ordinary service on its own mercury platform, in every serving landscape (raichu and ampharos). DECIDED · ARCH:431-437
  • In-cluster, plain DNS, no Cloudflare in the path. DECIDED · ARCH:555-556
  • Hot data (dedup, queue) in each landscape's Upstash; long-lived data in one Neon database for mew. DECIDED · mercury:176, 186
  • Which region that Neon sits in is not named. Proposed: raichu. MISSING PROPOSED · decision 44

One webhook, step by step

One Stripe webhook for tin

VendorStripeDNSmew name: nearest healthylandscapeMercury in raichuMercury in ampharosVerify, dedup, save + queue,reply 200Ask every receiverOne 200, rest 421:doneRetry up to 72 h,then dead-letter

  1. Vendor Stripe calls hooks.webhook.mercury.mew.cluster.atomi.cloud/… with payment.succeeded. DECIDED · onboard:351
  2. DNS Route 53 answers with the nearest healthy landscape, say raichu. DECIDED · ARCH:552-554 NOT TESTED · geoproximity not tested, how:127
  3. DNS raichu has no healthy address, so its set leaves the mew name. Route 53 answers with ampharos. DECIDED · ARCH:302-304
  4. Mercury checks the signature against the stored URL, never the request's host. DECIDED · ARCH:507-512
  5. Mercury in ampharos verifies and saves it in ampharos's own Upstash. DECIDED · ARCH:302-304
  6. Dedup: "seen this event id?", kept 72 h. Then it saves and queues the event, and only then answers Stripe 200. DECIDED · mercury:213, 253
  7. It asks every possible receiver "is this yours?". Exactly one must say 200; all others say 421 ("not mine"). DECIDED · mercury:283-287
  8. Done. Each delivery carries a fresh mercury signature; delivery is at least once, so receivers must cope with repeats. DECIDED · ARCH:522-525; mercury:257
  9. Anything else (no owner, two owners, errors) is refused loudly and retried, from seconds to hours, for 72 h; then the dead-letter queue. DECIDED · mercury:283, 337-339
  10. Events already saved in raichu's queue wait until raichu's mercury is back; there is no takeover rule. MISSING PROPOSED · G-5: the surviving region drains the dead region's Upstash
  • 421 ("Misdirected Request") means only "not mine". DECIDED · mercury:287
  • A retry may land in the other landscape and be delivered twice; receivers must ignore repeats. DECIDED · mercury:257
  • About 24 h of continuous failure to one receiver opens a breaker until a probe or a person re-enables it. DECIDED · ARCH:531-533
  • A receiver in the same landscape that allows private calls is called in-cluster; others by public name. DECIDED · mercury:307

Who sets it up

  • Each consumer platform has one internal tenant (WebhookEngine in its carbon chart, e.g. internal/nitroso). DECIDED · ARCH:466-484; T3:3171
  • Each receiver service adds a WebhookRoute in its own chart (e.g. tin). DECIDED · ARCH:466-484
  • T3's webhook controller turns those into calls to mercury's management API; only mercury writes its own Upstash config. DECIDED · T3:3171
  • A tenant's home name is fixed for life; registered intake URLs are treated as permanent. DECIDED · mercury:141; T3:3201
  • Previews: one fixed mercury endpoint per provider (the "callback rail"); the old per-preview tunnel is deleted. DECIDED · public-callback-tunnel.md:6, 155, 196

When things die

What diesWhat happensSource
Primordialmercury's config is frozen; intake keeps workingDECIDED · T3:3107
raichu (whole landscape)its set leaves the mew name; providers land in ampharos, which verifies and saves in its own UpstashDECIDED · ARCH:302-304
Events already saved in raichu's queuewait until raichu's mercury is back (within 72 h); no takeover ruleMISSING
Both raichu and ampharosno buffer; providers retry (often for days)DECIDED · ARCH:342-348

Still open

  • Which issuer for mercury's management API, which sits behind the mew name while tokens are per landscape? Proposed: the caller's own landscape issuer; needs a security review. MISSING PROPOSED · decision 43
  • Queued events in a dead region: proposed, the surviving region's mercury drains the dead region's Upstash (a vendor service, likely still up). PROPOSED · decision 45
  • Older text puts WebhookEngine in mercury's own chart; newer text puts it in the consumer's carbon chart. OPEN · GAPS G9

10 · Must settle, then all open decisions

What you decide

Three gaps must be settled before building. Then 48 smaller decisions, each with a default you can just accept.

  • Tick "Default" (or "Agree") to accept. Untick only what you disagree with. PROPOSED · this site
  • The reply line at the top builds itself; copy it and paste it to jaeda.
  • Your ticks stay in this browser only.

Your reply to jaeda

defaults; jaeda's positions ok

Ticks and the reply line are also at the bottom of this screen.

The 3 gaps to settle first

G1. Is production one "serving" landscape or four landscapes? (contradiction, corpus main vs corpus main)

The gap

  • ARCH:78-79, 367 and goals/fleet.md:210: pichu, pikachu, raichu, ampharos are separate landscapes; one issuer each; names api.tin.nitroso.raichu.cluster.atomi.cloud.
  • lop:558-568 and hw:457-462: all four are rows of one serving landscape, one issuer and audience; names api.zinc.nitroso.serving.cluster.atomi.cloud, row only as an extra slot.
  • Why it matters: DNS names, issuers, audiences, home-sharding (ARCH:112), failover between raichu and ampharos, and whether dev and prod share a user pool all change.
  • Neither is clearly newer. lop calls itself the "ruled fleet auth model" (lop:570).

jaeda's position PROPOSED · jaeda, POSITIONS.md

pichu, pikachu, raichu, ampharos are FOUR separate landscapes, one sign-in issuer each. "serving" is the name of the group (the class of landscapes that run apps for users), not a landscape. Why: ARCHITECTURE, fleet.md, the newer T3 goal draft and every owner walk-through (D1: a landscape has ONE region) agree; one landscape cannot have two regions. The logto-operator / helm-wrapper text that folds them into one serving landscape is the stale one. Names: <svc>.<platform>.<landscape>.cluster.<base>.

G2. The DNS chain, and T3's traffic text writes the wrong host.

The gap

  • The spare base (S8) sits on Route 53, the same host as the main answers (ARCH:353-358). Route 53 down = both dead. Owner asked for a fix (owner D36) → Brief 2's PROPOSAL-DNS.md.
  • T3:2856 says the loop "writes CF only on delta"; every write rule in T3's traffic section is Cloudflare-only. ARCH:227, 304 and T3:139 make Route 53 primary plus the S8 group. No Route 53 write rules, limits, TTL, geoproximity rule or DoD row exist in T3 (geoproximity appears once, T3:3491).
  • TTL: ARCH:230 "30–60 s"; MASTER locked 30 s; T3:2860 60 s (newest). Route 53 30 s is untested.

jaeda's position PROPOSED · jaeda, POSITIONS.md

follow model/proposal/PROPOSAL-DNS.md when it lands. Until then: the traffic controller writes EVERY host in one loop (primary, mirror, spare), the spare base must not share a DNS host with the primary, TTL 60 s on Cloudflare and 30 s where the host allows.

G3. Nothing makes an app wait for its database on a first release.

The gap

  • One Kargo pin moves the primordial and app deploys together (spike A:112). db-init goes red on a missing database (rules:665-670; dotnet-api:289), but no retry rule, no ArgoCD sync retry, and no ArgoCD health check for Database / KeyValue / Cache / Store / Email records exist (only CloudflareDeploy, T3:3352). The Kargo gate may pass on app health alone.

jaeda's position PROPOSED · jaeda, POSITIONS.md

(a) ArgoCD custom health for Database / KeyValue / Cache / Store / Email (Healthy only when the record is Ready and its secret is written); (b) the primordial app syncs before the app chart (sync wave or Kargo step order), and the Kargo stage gate needs BOTH healthy; (c) the app pod waits for its secret (it cannot start without it) and db-init retries with backoff. No human step.

Also proposed by jaeda

G5. Sign-in during a dead DNS name has no tested option, and the issuer is permanent.

The gap

Logto OSS cannot be one issuer on two domains (logto@v1.46.0, chapter 4). Option D (issuer on its own DNS host) must be chosen before go-live or never (alog:102-105).

jaeda's position PROPOSED · jaeda, POSITIONS.md

accept "signed-in users keep working until the pass ends; nobody new signs in" for v1, and put the issuer name on a base that the DNS proposal makes survive one host outage. Do not run Logto as two issuers.

G10. Rescue router: not built, not tested, partly unowned.

The gap

bun-api-engine's DoD has no retry-once row and no trip-point row (bun-api-engine:192-200). No library sets a connect time limit (dart-api-engine:77; go-api-engine:63), so a silent cluster stalls clients. Browser storage for Doc C, certificates and CORS on spare names: not written. "Rescue domain on R53" (ARCH:350) and "S8 backup base" (ARCH:353): same domain or two? Not said.

jaeda's position PROPOSED · jaeda, POSITIONS.md

every client library sets a connect time limit (2 to 3 s), retries once on another address when the request is safe, and ships the rescue router. Add the missing DoD rows.

G11.

The gap

"Dead cluster leaves DNS in ~5–10 s" (ARCH:292, 308; how:122) is impossible under 3 failed probes × 5 s (T3:2937); measured 13–19 s.

jaeda's position PROPOSED · jaeda, POSITIONS.md

say "13 to 19 s" (3 failed checks x 5 s + DNS), not "5 to 10 s".

All 48 open decisions

Deployment (chapter 1)

1Drop services.yaml (O1)

Default: default yes, list from row names.

2Cloud account for clusters (O6)

Default: default fleet/registry/accounts/.

3One secret store per platform per cluster (O7)

Default: default adopt.

4Which Infisical is authoritative (O8)

Default: default: leave as a setting.

5Tier on a landscape is only a label (O5)

Default: default unchanged.

6Row layout (pick 3)

Default: default by service platforms/nitroso/tin/pichu.yaml (test first).

7Machine files in their own fleet-state repo (pick 4)

Default: default yes (test first).

8Young/mature/scaled lives on the platform (pick 6)

Default: default yes, with a check.

9Per-landscape settings live in the service chart (pick 7)

Default: default yes.

10Who refuses a landscape with zero clusters

Default: default the compiler chart.

11Database-not-ready rule on first release

Default: default db-init retries 10 min + ArgoCD health checks for dependency records PROPOSED.

12Schema rollback

Default: default expand-then-contract, one release backward-compatible PROPOSED.

13Keep people's two fleet edits (emergency values:, break-glass)

Default: default yes PROPOSED.

14Proxy in front of every dependency vs credential swap in the app library (owner D25)

Default: jaeda: library first, proxy only for apps we cannot change.

Cluster and failover (chapter 2)

15DNS chain fix (owner D36)

Default: see PROPOSAL-DNS.md.

16TTL

Default: default 60 s everywhere until Route 53 30 s is tested.

17Client connect time limit

Default: default 3 s (jaeda: 2–3 s).

18Minimum clusters per prod landscape

Default: default 2.

19Who does dark-verify and what it checks

Default: default automated smoke test, then a person flips traffic.

20Where "< 0.1 % traffic for 1 h" is measured

Default: default gateway metrics in Grafana Cloud.

21Cloudflare in front for faster failover

Default: default no (owner D17); pricing open.

22Freeze rule fleet-wide or per landscape

Default: default fleet-wide, as written.

Frontend (chapter 3)

23Owner of the weekly Vercel test and the switch (OD-25)

Default: default A: a production-ops node under T3.

24Who flips the CNAME

Default: default a person, one-command runbook, no automatic flip in v1.

25Web name and zone

Default: default web.<svc>.<p>.<l>.cluster.atomi.cloud, written by the traffic controller.

26Vercel build and Cloudflare-only parts

Default: default a second "standby" build, page cache off.

27One build per landscape issuer

Default: default yes.

28Problem catalog unit

Default: default per platform × tier (newest text); confirm.

29Error portal template adoption (OD-15, with OD-11)

Default: not ruled; no default given in corpus.

Logto (chapter 4)

30One "serving" issuer or one per landscape (GAPS G1)

Default: default one per landscape (ARCH §5).

31Spare sign-in path (options A–F)

Default: default F if the DNS proposal allows, else C.

32Logto version pin

Default: default v1.46.0.

33Passkeys

Default: default off in v1.

34Machine token lifetime

Default: default 10 min, cached to expiry.

35Signing-key rotation

Default: default staged, grace ≥ access token life.

36Social callback addresses

Default: default one per landscape issuer, listed in LogtoInstance.

37ENDPOINT / admin console settings

Default: default ENDPOINT = issuer host, proxy header on, admin console not public.

38Prod Logto database

Default: default Neon Scale in the landscape's region, no region copy.

Global (chapter 5)

39Migration record (O2)

Default: default yes.

40Standby database at a second vendor (O3)

Default: default no.

41Home per user or per platform

Default: default per user, in the landscape's Logto.

42New-device user picking the wrong home

Default: default check other landscapes after sign-in and offer to switch.

43Issuer for mercury's management API behind mew

Default: default caller's own landscape issuer; needs a security review.

44Mercury's Neon region

Default: default raichu.

45Queued events in a dead region

Default: default the surviving region drains the dead region's Upstash.

46Re-home tool

Default: default a manual, audited runbook in v1.

47Airport codes

Default: default raichu = SIN, ampharos = IAD.

48Where the database fence runs for far-away databases

Default: default a small fence helper per region.

Also open in the T3 goal but outside these chapters: O9–O14 (Garden rotation, Upstash alternative, Valkey operator licence, Aurora in v1, R2 as durable store, Aurora upgrades) – see T3:611-667. celebi's hosts default [pikachu] (fleet.md:234).

Your reply to jaeda

defaults; jaeda's positions ok

Every gap found while writing

Serious gaps (G4 to G10)

G4. Seven of spike A's 13 fixes are not in the T3 goal. Holes 2, 4, 5, 7, 9, 12, 13 live only in the unmerged fleet edit (spikes/lock/PR-BODY.final.md:76-85); 2, 5, 7, 12 are unproven. jaeda's dm/a/flow.md:34 says "all in the T3 goal".

G5. Sign-in during a dead DNS name has no tested option, and the issuer is permanent. Logto OSS cannot be one issuer on two domains (logto@v1.46.0, chapter 4). Option D (issuer on its own DNS host) must be chosen before go-live or never (alog:102-105).

G6. Home claim: "per platform" vs one key. ARCH:113 says a home per platform; Logto is one per landscape for every platform with one custom_data.home_landscape key (goals/aldehyde-logto.md:407). Also missing: new-device users can make a second account in the other region; no re-home mechanism although landscape removal needs one (ARCH:914); travelling users.

G7. Web failover has no owner and no mechanics. Who flips the CNAME to Vercel, by what signal, which token: not written. Web hostname shape and zone: not written. Vercel has no plan for Cloudflare-only parts (R2, D1, Durable Objects, Images; nextjs:296-298). OD-25 (probe owner) open.

G8. One frontend build vs one issuer per landscape. nextjs:332-335 says one Worker build promotes through "issuer-compatible stages" and a different issuer needs a new build; with one issuer per landscape every stage is a different issuer, so "promote one Freight" does not hold for frontends.

G9. Mercury contradictions.

  • WebhookEngine home: mercury.md:150-152 (mercury's chart) vs ARCH:477-478 and T3:3094-3096 (consumer's carbon chart; newer).
  • Mercury's mew-wide Neon: rendered by mercury's service chart (mercury.md:150-151, onboard:159-160) vs T3:3692-3695, which refuses that and needs carbon (newer).
  • Its management API sits behind mew, but tokens are per landscape: which issuer? Not answered.
  • Events already queued in a dead region's Upstash: no takeover rule. Neon region not named.

G10. Rescue router: not built, not tested, partly unowned. bun-api-engine's DoD has no retry-once row and no trip-point row (bun-api-engine:192-200). No library sets a connect time limit (dart-api-engine:77; go-api-engine:63), so a silent cluster stalls clients. Browser storage for Doc C, certificates and CORS on spare names: not written. "Rescue domain on R53" (ARCH:350) and "S8 backup base" (ARCH:353): same domain or two? Not said.

Medium gaps (G11 to G20)

G11. "Dead cluster leaves DNS in ~5–10 s" (ARCH:292, 308; how:122) is impossible under 3 failed probes × 5 s (T3:2937); measured 13–19 s. G12. Decommission gate: text = traffic < 0.1 %/1 h AND no empty v-landscape name (T3:824); DoD tests "traffic:true or probes pass" and skips the empty-name check (T3:916). Who measures 0.1 % and where: not written. G13. Geoproximity: ARCH:302 deletes a landscape's whole set when it has no healthy IP; T3:2919 never removes a name's last record. Coordinates have no field, no writer, no values. G14. Primordial's region is not written, but the database fence needs the operator in each database's region (T3:1676). G15. Logto: no version pinned; 10-minute access only if the operator writes it on every API (Logto default 1 h); refresh reuse detection not documented upstream (study I:155); signing-key rotation policy not written; social callback addresses not listed; production preview sanitizer unbuilt (risk R0, env:1486). G16. Ordinary-service rollback is not written (only Workers, ARCH:744); schema rollback after a forward migration is not written. G17. Service Decommission removes rows, but the materializer "never deletes rows" (T3:1039 vs 1052): which controller does it? Landscape removal: "refused while referenced", but Landscape has no reconciler (T3:3475). G18. Owner D3 says people touch fleet only for landscapes/clusters; fleet.md:112 still lets people edit row values: and make break-glass commits. G19. Region codes: regions.yaml not defined (MASTER:538); raichu/ampharos codes and the US city never written. Spike A used regions.yaml as a landscape list (spike A:35), T3 as a code table. G20. The rule "a ~1 min stale window is fine" assumes outside callers retry (ARCH:44); nothing tells partners to.

Small: wording and stale text
  • ID clash: corpus D7, D11, D21 (mercury) ≠ owner D7, D11, D21. Use the DM- prefix (T3:431) or "owner D…".
  • machinery-stable rollback: ARCH:715 "move the tag back" vs fleet.md:376 "never moves backward" (detailed rule).
  • fleet-root and compiler chart under registry/ (fleet.md:149) vs machinery/ (owner D2; spike A).
  • OrphanedSource flips rows (ARCH:725; fleet.md:1162) vs rows not touched (T3:1036, newer).
  • Kargo objects made by the platform controller (secrets-chain:22; T3:3520) vs by the compiler chart (T3:976, newer).
  • Cluster account field providerAccountRef (fleet.md:212) vs spec.account (T3:602). CloudflareDeploy account: three names (T3:3288; spike C sketch; lens-t3-crs:176).
  • Cluster phases: 7-phase list (lens-t3-crs:41, with Crossplane) vs provisioning → ready → decommissioned (T3:820).
  • Ops wildcard written by "copper" (lens-adversarial-review:45) vs only the traffic controller (T3:2859).
  • Old ArgoCD "RollingSync" promotion (seed-cluster:103) vs Kargo.
  • Upstash "availability-only" (ARCH:662) vs owner D12 "durable key-value store".
  • PlatformDependency (secrets-chain:319; ARCH §7) vs five split kinds (owner D4, T3:3468).
  • Custom Infisical roles "licence-gated" (secrets-chain:53) vs disproven (T3:695-700).
  • Login via mew (ARCH:95; env:782; lens-adversarial-review:67) vs v-landscape issuer superseded (ARCH:371).
  • Problem catalog per service (ARCH:284) vs per platform × tier (portal:327; T3, newer).
  • ARCH §14 says every verification spike is green; its own note says no evidence backs it (ARCH:957).
  • Weekly Vercel probe in nextjs deliverables (nextjs:184) but struck from its DoD (nextjs:412).
  • celebi's hosts are an inference ([pikachu], fleet.md:234).

Words

Words used on this site

Every tool and every word we made up, in one line each.

  • Tap any dotted word on any screen to see its line here.
  • Source names (ARCH, T3, spike A …) are explained at the bottom.
  • Owner rulings are written "owner D…"; they override older design text. DECIDED · OWNER-DECISIONS

Tools we use

ArgoCD
makes clusters match what git says.
ApplicationSet (AppSet)
an ArgoCD rule that stamps out one deploy per file, repo or cluster it finds.
Kargo
moves a version from stage to stage by editing one file per stage.
Helm chart
a package of Kubernetes files with settings.
Infisical
our secret store, on Primordial.
ESO (External Secrets)
copies secrets from Infisical into each cluster.
cert-manager
gets TLS certificates from Let's Encrypt.
Route 53
AWS's DNS host; holds our main cluster answers.
Cloudflare
holds the atomi.cloud zone, a copy of our DNS, Workers and R2.
Cloudflare Workers
Cloudflare runs small programs at its edge; our web frontends run there.
OpenNext
turns a nextjs app into a Worker.
Vercel
a nextjs host; our standby for web.
R2 / S3 / CloudFront
file hosting at Cloudflare / AWS / AWS's CDN.
Logto
open-source sign-in server (OIDC).
Neon, PlanetScale, DigitalOcean Postgres, Aurora
hosted Postgres vendors.
Upstash
hosted key-value store (Redis protocol), durable.
Dragonfly / Valkey
in-cluster caches.
Tigris
hosted file store.
Grafana Cloud
where metrics and alerts go.
k3s / k3d
small Kubernetes / k3s in docker (used in tests).
Gitea
a self-hosted git server (stood in for GitHub in tests).

Words we invented

Landscape
one environment with its own data: pichu, pikachu, raichu, ampharos.
Virtual landscape (v-landscape)
a DNS name over several landscapes (mew, celebi); owns nothing else.
Platform
a product (nitroso); one <p>.carbon repo.
Service
one part of a platform (tin); one <p>.<svc> repo.
Module
one named thing inside a service (api, web, maindb).
LPSM
Landscape / Platform / Service / Module: every thing's four-part address.
Primordial
our control cluster (ArgoCD, Kargo, Infisical, T3); safe to be down for hours.
T3 / fleet-operator
our operator: clusters, platforms, dependencies, DNS, webhooks, Workers.
Materializer
the part of T3 that writes empty row files when a service repo appears.
Traffic controller
the part of T3 that runs the 5 s health check and writes DNS.
cf-deploy controller
the part of T3 that rolls out Worker versions.
Carbon
a platform's home repo (nitroso.carbon): platform.yaml and shared charts.
fleet
the one public deploy repo: registry/ (people), machinery/ (static), platforms/ (machines).
Row file
one file per service per landscape saying which version runs there.
App chart / primordial chart
the two charts every service ships: pods (to clusters) / outside needs (to Primordial).
Compiler chart (diene-platform)
the chart in fleet/machinery that turns platform.yaml into Kargo and AppSets.
machinery-stable
the git tag real platforms use for the compiler chart.
Mark / gem
a cluster's second name (opal, ruby) / sibling clusters of one landscape.
traffic: true/false
a cluster's switch: in or out of DNS.
Dark-verify
test a new cluster on its own name before users arrive.
Decommission
the record that really deletes something (name typed twice).
OrphanedSource
the alarm when a service repo is deleted; nothing is removed.
Gray / orange
plain DNS / behind Cloudflare's proxy (orange is unused).
S8 / backup base
a second base domain on Route 53 with the same addresses.
Rescue domain
a domain on Route 53 that reaches the AWS copy of the address books.
Rescue router
app code that, only when a name cannot connect, tries spare addresses from Doc C.
Doc A / B / C
edge files: where the books are / which landscapes exist / full address book.
Allowlist
the built-in list of our own domain endings; any other address is refused.
Issuer
the exact sign-in address in every token.
Lithium / aldehyde
our Logto element / the platform that owns it.
logto-operator
our controller that manages apps and settings inside Logto.
LogtoApp / LogtoResource / LogtoInstance
records for an app / an API audience / one landscape's Logto settings.
Home landscape / home claim
the user's region / the field in Logto that says it.
Doc B ping-pick
at first sign-in, ping each landscape and pick the home.
Mercury
our webhook intake platform.
WebhookEngine / WebhookRoute
a platform's webhook tenant / one receiver's route.
421 rule
mercury asks every receiver; exactly one says 200 ("mine"), all others 421 ("not mine").
72 h obligation
mercury keeps retrying a webhook for 72 h, then parks it in a dead-letter queue.
Callback rail
the fixed mercury endpoints that receive vendor callbacks for previews.
Migration record
the record that moves a database, store or key-value store to another vendor, account or region.
Ledger
T3's memory of everything it created, in an R2 bucket outside Primordial.
Garden
dev/test/preview environments (lapras, ditto, rotom, absol, eevee, castform), each with its own Logto.
ENTEI
the infrastructure cluster that hosts Garden previews; never serves users.
Problem catalog / erbium
each service's known-error list / the human error portal on Cloudflare.
boron
private admin access through Cloudflare tunnels.
SoS
the Infisical area for operator-internal secrets.

Source names

  • ARCH = corpus concepts/ARCHITECTURE.md. fleet.md = corpus goals/fleet.md. carbon.md = corpus goals/charts/carbon.md.
  • T3 = T3 goal draft, PR 71, goals/dependency-operator.md (branch goal/t3-lock-operations). Newer than corpus main.
  • spike A = spikes/a-deploy/REPORT.md (real ArgoCD v3.6.1 + Kargo v1.12.5 on local k3d, T3 played by scripts, 11 Nov).
  • spike B = spikes/b-secrets/REPORT.md (secrets chain). picks = jaeda's dm/a/picks.md. rules = corpus rules/SAMPLE-CREATING-RULES.md.
  • Owner rulings D1–D36 = spikes/lock/OWNER-DECISIONS.md (T3 calls them DM-D1…).
  • ARCH = corpus concepts/ARCHITECTURE.md (main branch).
  • T3 = the T3 goal draft, PR 71, goals/dependency-operator.md on branch goal/t3-lock-operations. Newer than ARCH.
  • spike D = spikes2/d-cfdns/REPORT.md (real Cloudflare DNS, 200 pretend users, 6 pretend clusters, 11–12 Nov).
  • spike G = spikes/g-control/REPORT.md (local PowerDNS stand-in, 11 Nov).
  • how = jaeda's plain notes agenda-deps/v2/how.md. lib-contracts = corpus concepts/lib-contracts.md.
  • ARCH = corpus concepts/ARCHITECTURE.md. T3 = T3 goal draft, PR 71 (newer than ARCH).
  • nextjs = corpus goals/nextjs-frontend.md. portal = goals/error-portal.md. bfu = goals/lib/bun-frontend-utils.md.
  • spike C = spikes/c-cfdeploy/REPORT.md (real Cloudflare Workers API, 11 Nov). spike G3 = spikes/g-control/REPORT.md part G3. spike D = spikes2/d-cfdns/REPORT.md.
  • ARCH = corpus concepts/ARCHITECTURE.md. T3 = T3 goal draft, PR 71 (newer).
  • alog = corpus goals/aldehyde-logto.md (our Logto fork and charts). lop = goals/logto-operator.md. lib-contracts = concepts/lib-contracts.md. env = concepts/environments.md. hw = goals/helm-wrapper.md.
  • spike E = spikes/e-localops/REPORT.md (Logto rolling upgrade, local). study I = spikes/i-signin/REPORT.md (docs-only).
  • logto@v1.46.0:<file>:<line> = Logto's open-source code on GitHub, tag v1.46.0 (latest 1.x, 5 Nov 2026), read with gh.
  • how = jaeda's notes agenda-deps/v2/how.md.
  • ARCH = corpus concepts/ARCHITECTURE.md. T3 = T3 goal draft, PR 71 (newer). fleet.md = corpus goals/fleet.md.
  • mercury = corpus goals/mercury.md. onboard = goals/mercury-onboarding.md. lib-contracts = concepts/lib-contracts.md. env = concepts/environments.md.
  • logto = corpus goals/aldehyde-logto.md. lop = goals/logto-operator.md. spike M = spikes2/m-pgmoves/REPORT.md.
  • ID warning: the corpus has its own "D11", "D21", "D7" (e.g. mercury's callback rule). They are not the owner's D11/D21/D7. Owner rulings here are written "owner D…". see the gaps
  • GAPS = model/content/GAPS.md. POSITIONS = jaeda's proposed answers, model/POSITIONS.md. how / how.md = jaeda's plain-words answers, agenda-deps/v2/how.md.

All screens