PoC: minimal live deployment (control plane + Stalwart, active/passive FDB)
closedGoal
Minimal proof of concept deployable on a live server: control plane + Stalwart, thin scope (no on-protocol spaces/postmaster/reconciler yet). Phase 0 risk reduction is complete (all C1-C7 closed, exit gate 0d541aa passed); this issue charts the path from walking skeleton to live deploy.
Decisions (locked)
- Deployment: Nix/devenv for dev only; Ansible + Debian for prod (
docs/deployment.md). - Stalwart store: FoundationDB (prod) / SQLite (dev); app DB: SQLite everywhere — ADR-0008.
- Failover: active/passive + FDB + VIP + FDB leader-lease fence; active/active (coordinator) deferred to Q8 — ADR-0008.
- Read-mail proof: app password + off-the-shelf IMAP/JMAP client (no webmail fork yet).
- App view UI: minimal server-rendered skeleton — templ + Tailwind v4, plain forms + tiny JS. “Add hosted domain” = custom mode (user’s own domain + MX/SPF/DKIM).
Stages
- [x] S1 — ADR-0008 failover/storage; fix
docs/deployment.md; mark Phase 0 exit onf98ccdc. - [x] S2 — Stalwart-on-FDB build + harness; re-probe C2 lifecycle/DKIM/pagination against FDB.
- [x] S3 — Replace
DevDIDauth with real service-auth JWT verification (svcverifier); wireauthbroker/tokenissuer/appwdintorouter.go; app-password issue/revoke XRPC; app-password→IMAP read-mail proof. - [ ] S4 — App view UI skeleton (templ + Tailwind v4, plain forms + tiny JS): login → home → add custom domain → DNS records → add accounts → IMAP/app-password guide. Adds
owner_didto tenants + session-cookie auth (cookie → ResumeSession → DID). - [ ] S5 — Ansible roles/playbooks (
common/stalwart/foundationdb/caddy/goapp/dns); packagesovrnd+ Stalwart-FDB; Caddy TLS + real domain + MX; live smoke test. - [ ] S6 — Tracker housekeeping: annotate/close stale Phase-1 bugs, resolve
9d84518pagination + re-verifycf71374under manual DKIM.
Out of scope (thin PoC)
Postmaster DID ceremony, spaces PDS, spaceproj publisher, reconciler three-way sweep, lexicon network publication, webmail fork, app-view UI polish (skeleton only), active/active failover.
Basis
Phase-0 tracker f98ccdc (C1-C7 closed); ADR-0002, ADR-0006, ADR-0007, ADR-0008; docs/deployment.md; S2/S3 findings in comments.
35 Comments
S2 complete — Stalwart-on-FDB de-risked (2026-09-08)
Stage 2 is done and verified live. The FDB seam is no longer an unknown.
What changed (repo)
nix/pkgs/stalwart.nix: addedfoundationdbbuild feature (OSS, not enterprise-gated) +postPatchpinning the bindingsfdb-7_4 → fdb-7_3to match nixpkgs’foundationdb7.3.68 (a 7.4-api client rejects the 7.3 libfdb_c at runtime).pkgs.foundationdb.libadded as link input.nix/images/stalwart.nix: image now carriesfdbserver/fdbcli+libfdb_c.so.nix/scripts/serve-stalwart.sh:SOVRN_DATASTORE=fdbmode — starts a local single-process fdbserver, configures the DB once (single ssd, sentinel-gated), bootstraps Stalwart againstFoundationDb. Restart path reuses the persisted cluster. sqlite mode unchanged (/data/config.json).devenv.nix: passesSOVRN_DATASTOREthrough (default sqlite).Verification (all against the live FDB harness)
TestIntegrationProvisionUserStory(C2 lifecycle),TestIntegrationAppPassword(IMAP/SMTP app-password round-trip),TestIntegrationOIDCBearerAuth,TestIntegrationAppViewXRPCEndToEnd,TestIntegrationAppViewCustomDomainDevGate,TestIntegrationHappyPathLexicon— all green, identical behavior to SQLite.Coordinator::Disabledis the default —crates/registry/src/schema/enums.rs:264).objectIsLinkedguard fires withlinkedObjects=[DkimSignature…]; destroy-signatures → delete-domain leaves zero orphaned signatures. Seecf71374.Findings
fdb-7_3pin is a one-line Stalwart fork. Per docs/05 §5 it is AGPL and must be published; tracked innix/pkgs/stalwart.nixcomment + ADR-0008.internal/integration/appview_test.gocleanup usest.Context(), which is already canceled whent.Cleanupruns, so domains/tenants/DkimSignatures leak after each run. Story/oidc/apppassword tests use a fresh background context and clean up correctly. Worth a follow-up bug; not a blocker.9d84518(pagination) remains open and unchanged — PoC-scale is unaffected; address before prod scale.Unblocks S3 (auth + app-password wiring) and S4 (Ansible deploy) — no FDB-specific work remains in the critical path.
S3 complete — real auth + app-password wiring (2026-09-08)
The auth seam is no longer a stub. All changes are green against the live harness.
What changed (repo)
internal/svcverifier(new) — wraps indigo’sServiceAuthValidator: bearergetServiceAuthJWTs verified against the caller’s DID-doc signing key (key-refresh on failure),aud == service DID, exp/iat enforced, optional lxm (route NSID) match. Unit-tested (valid, wrong-aud, lxm-mismatch, expired, unknown-signer).internal/appview/auth.go—Auth{DevDID, Verifier}: bearer service-auth is verified and the subject DID injected; a request with no bearer falls back toDevDID(development) or is rejected 401.DevDIDis no longer an unconditional default.at.sovrn.mail.createAppPassword/listAppPasswords/removeAppPassword(+defs#appPasswordView), regenerated types, and handlers ininternal/appviewissuing/listing/revoking viainternal/appwd.router.go— top-level dispatcher:/xrpc/*behind auth;/.well-known/openid- configuration+/jwks(tokenissuer, unauthenticated);/oauth/*(authbroker client-metadata/login/callback, unauthenticated, enabled only whenoauth.clientidset).internal/authbroker/handler.goadded.config.go—oidc.keyfile,service.did,oauth.{clientid,callbackurl,keyfile}.Verification
TestIntegrationAppPasswordXRPC(live): tenant→domain→account →createAppPassword→ IMAP LOGIN succeeds (the read-mail proof) →listAppPasswords→removeAppPassword→ IMAP rejects the revoked secret. Passes.TestRouterOIDCAndOAuthEndpoints: discovery doc (issuer correct), JWKS, OAuth client-metadata, and 401-without-credentials. Passes.TestRouterDevAuthAllowsXRPC: DevDID path still serves XRPC. Passes.Notes / remaining
authbroker) is wired but not exercised end-to-end here — a full flow needs a publicly reachable client-metadata URL + live PDS. The confidential client is constructed whenoauth.clientidis set; attestation key persists tooauth-attestation.pem(dev-file provider; KMS in prod).svcverifieruses indigo’sidentity.DefaultDirectory()(PLC + did:web + caching) — production may want a bounded resolver; flagged for later, not PoC-blocking.Unblocks S4 (Ansible deploy): the binary now exposes authenticated XRPC + OIDC metadata + OAuth front-of-house + app-password issuance.
S4 complete — app-view UI skeleton (templ + Tailwind v4) — 2026-09-09
The browser app-view exists as a minimal server-rendered skeleton covering the six user-testing steps. All green against the live harness.
What changed (repo)
internal/appview/ui/(new): templ components (layout/login/home/domain/account), HTTP handlers, a browser-session auth middleware, and embedded static assets. Tailwind v4 (CSS-first:@import "tailwindcss";+@source "../*.templ";), compiled by the standalone CLI intostatic/tailwind.css;static/app.jsis ~10 lines of copy-to-clipboard JS.GET /login(open),/static/*(open), and the session-authed catch-all/(home, domains, accounts, app passwords). Session auth reads thesovrn_sessioncookie →broker.ResumeSession→ DID; falls back toDevDIDin development.internal/store:tenants.owner_didcolumn (+ in-placeALTER TABLEmigration),GetTenantByOwnerDID; the UI ensures one tenant per DID and lists that tenant’s domains.internal/appview/provision.go: extractedProvisionDomain/ProvisionAccountso the XRPC handlers and the UI share one provisioning path.router.go: retains the*authbroker.Broker; mounts UI routes last so XRPC/OAuth/OIDC/static win over the catch-all.go.modaddsa-h/templ v0.3.1020;devenv.nixaddspkgs.templ+pkgs.tailwindcss_4;Justfile gennow generates./api/sovrn ./internal/appview/ui;ui/generate.gocarries//go:generatefor templ + tailwind.Verification
TestIntegrationAppViewUI(live harness): home → add domain (custom) → DNS page shows MX/SPF/DKIM → add account → IMAP guide → issue app password → IMAP login succeeds with the UI-issued secret.ui_test.go):buildDNS, home/domain rendering via the real route+mux, session DevDID fallback, 303→/loginwithout a session.go vet+gofmtclean.Notes / remaining
GET /login→POST /oauth/loginrequires a reachableoauth.clientid+ a PDS that completes the flow. The post-login flows are verified via theDevDIDsession fallback; the real atproto OAuth round-trip is the one remaining S4 item for a public server and stays a documented follow-up.Unblocks S5 (Ansible deploy): the binary now serves the full user-testing loop over HTTP.
S5 — Ansible secrets pattern landed (vault file → 0600 host file)
Implements the secrets half of S5 per the revised 2964546 direction: no secret is ever generated at runtime; everything is ansible-vault-encrypted in-repo and materialized as files. This is the pattern all future services follow.
Files (all new, follow docs/deployment.md layout)
inventory/hosts.yml— primary / backup / staging poolgroup_vars/all/sovrn.yml— non-secret vars:sovrn_secrets_dir(/etc/sovrn/secrets), the three host secret pathsgroup_vars/all/vault-shared.yml— fleet-wide OIDC PEM + OAuth attestation key (base64); plaintext placeholder, operator pastesjust gen-secretsoutput thenansible-vault encrypthost_vars/primary.sovrn.example/vault.yml— per-host Stalwart recovery-admin password; copy per hostroles/sovrn_secrets/tasks/main.yml— asserts shared vault vars are real values (fails on placeholders), writes both keys 0600no_log: trueroles/stalwart/tasks/secret.yml— per-host credential:mode=bootstrapgenerates once on the controller + prints a persist-back reminder; otherwise asserts the vault value exists (recover/update)playbooks/site.yml— entry point,-e mode=bootstrap|recover|update(default update, matching docs/deployment.md)Key decisions
sovrnd. Dev Stalwart credential stays owned by the container entrypoint (nix/scripts/serve-stalwart.sh);just gen-secretsdeliberately does not create one (a second credential would mismatch the DB-persisted recovery admin and be rejected).assert, same philosophy as the app.Verification
go test ./...green (incl. new fail-fast tests); all 7 Ansible YAML files parse.sovrndwith no secrets exits 1:config: stalwart.secretfile is required ....go run ./cmd/sovrn-gensecretswrites both keys 0600 and refuses to overwrite.Remaining S5 (not this change): full common/foundationdb/caddy/goapp/dns roles, packaging, TLS/MX, live smoke test.
S5 single-server smoke (sovrn.at) Implementation Plan
Goal: Bring up one ephemeral live server (
sovrn.at, MXmx1.sovrn.at) with control plane + Stalwart on single-instance FDB, all identity URLs as paths offhttps://sovrn.at.Architecture: Ansible renders a static public
did.json(no secret) and asovrn.tomlwith locked single-server values; Caddy terminates TLS forsovrn.atand serves/.well-known/did.jsonas a static file ahead of thesovrndreverse-proxy. FDB runs single-instance (single ssd, local cluster file); backup role is a no-op for smoke with anrclone-R2target var reserved for prod.Tech Stack: Ansible (vault-encrypted secrets already landed), Caddy, FoundationDB single-process, Go
sovrnd(viper TOML+env),did:web+ atproto service-auth.Locked decisions (user):
service.did=did:web:sovrn.at#svc_mailwith fragment;serviceEndpoint=https://sovrn.at(bare host);did.jsonhas NO private key — static public file provisioned by Ansible, served by Caddy; FDB smoke = single instance, no backup; prod backup = R2 via rclone (var only, empty for smoke).Task 1: Single-server non-secret vars
Files: - Modify:
group_vars/all/sovrn.ymlgroup_vars/all/sovrn.ymlRun:
python3 -c "import yaml,sys; yaml.safe_load(open('group_vars/all/sovrn.yml')); print('YAML OK')"Expected:YAML OKTask 2: Single-server inventory
Files: - Modify:
inventory/hosts.ymlRun:
python3 -c "import yaml,sys; yaml.safe_load(open('inventory/hosts.yml')); print('YAML OK')"Expected:YAML OKTask 3: didwellknown role (static did.json, no secret)
Files: - Create:
roles/didwellknown/tasks/main.yml- Create:roles/didwellknown/templates/did.json.j2Run:
ls roles/didwellknown/tasks/main.yml roles/didwellknown/templates/did.json.j2Expected: FAIL (No such file) before, PASS after creation.roles/didwellknown/templates/did.json.j2roles/didwellknown/tasks/main.ymlRun:
python3 - << 'EOF' from jinja2 import Template src = open('roles/didwellknown/templates/did.json.j2').read().split('{#')[0] import json doc = json.loads(Template(src).render(sovrn_service_did='did:web:sovrn.at#svc_mail', sovrn_did_document_endpoint='https://sovrn.at')) assert doc['id'] == 'did:web:sovrn.at', doc assert doc['service'][0]['id'] == 'did:web:sovrn.at#svc_mail', doc assert doc['service'][0]['serviceEndpoint'] == 'https://sovrn.at', doc assert 'verificationMethod' not in doc, doc print('did.json OK:', doc) EOFExpected:did.json OK: ...with bare-host endpoint and#svc_mailfragment, noverificationMethod.Task 4: Caddy role (single vhost + static did.json)
Files: - Create:
roles/caddy/tasks/main.yml- Create:roles/caddy/templates/Caddyfile.j2roles/caddy/templates/Caddyfile.j2roles/caddy/tasks/main.ymlroles/caddy/handlers/main.ymlRun:
python3 -c "import yaml; yaml.safe_load(open('roles/caddy/tasks/main.yml')); yaml.safe_load(open('roles/caddy/handlers/main.yml')); print('YAML OK')"Expected:YAML OKTask 5: goapp sovrn.toml (locked single-server values)
Files: - Create:
roles/goapp/tasks/main.yml- Create:roles/goapp/templates/sovrn.toml.j2roles/goapp/templates/sovrn.toml.j2Note:
oauth.clientiduses the real served path/oauth/client-metadata(not the stale dev/client-metadata.jsoninJustfile dev).devauth.didandpostmaster.didintentionally absent in prod.roles/goapp/tasks/main.ymlroles/goapp/handlers/main.ymlRun:
python3 - << 'EOF' from jinja2 import Template out = Template(open('roles/goapp/templates/sovrn.toml.j2').read()).render( sovrn_server_host='127.0.0.1', sovrn_server_port=8081, sovrn_datadir='/var/lib/sovrn', sovrn_env='production', sovrn_stalwart_url='http://127.0.0.1:8080', sovrn_stalwart_user='sovrn-admin', sovrn_stalwart_secretfile='/etc/sovrn/secrets/stalwart-secret', sovrn_mail_hostname='mx1.sovrn.at', sovrn_mail_spf='v=spf1 mx -all', sovrn_appview_url='https://sovrn.at', sovrn_oidc_issuer='https://sovrn.at', sovrn_oidc_audience='sovrn-mail', sovrn_oidc_keyfile='/etc/sovrn/secrets/oidc-key.pem', sovrn_service_did='did:web:sovrn.at#svc_mail', sovrn_oauth_clientid='https://sovrn.at/oauth/client-metadata', sovrn_oauth_callbackurl='https://sovrn.at/oauth/callback', sovrn_oauth_keyfile='/etc/sovrn/secrets/oauth-attestation.key') assert 'did = "did:web:sovrn.at#svc_mail"' in out, out assert 'issuer = "https://sovrn.at"' in out, out assert 'clientid = "https://sovrn.at/oauth/client-metadata"' in out, out assert 'hostname = "mx1.sovrn.at"' in out, out assert 'devauth' not in out and 'postmaster' not in out, out print('sovrn.toml OK') EOFExpected:sovrn.toml OKTask 6: FoundationDB single-instance (+ R2 var for prod)
Files: - Create:
roles/foundationdb/tasks/main.yml- Create:roles/foundationdb/defaults/main.ymlroles/foundationdb/defaults/main.ymlroles/foundationdb/tasks/main.ymlRun:
python3 -c "import yaml; yaml.safe_load(open('roles/foundationdb/tasks/main.yml')); yaml.safe_load(open('roles/foundationdb/defaults/main.yml')); print('YAML OK')"Expected:YAML OKTask 7: Wire playbook + verify
Files: - Modify:
playbooks/site.yml(Order: secrets → stalwart secret → didwellknown → foundationdb → caddy → goapp.)
Run:
SOVRN_INTEGRATION=0 go test ./... 2>&1 | tail -5Expected: all packages PASS (esp.svcverifierfragment-aud tests).Run:
for f in $(git ls-files 'roles/**/*.yml' 'inventory/*.yml' 'group_vars/**/*.yml' 'playbooks/*.yml' 2>/dev/null || jj file list 'roles/**' 'inventory/**' 'group_vars/**' 'playbooks/**'); do python3 -c "import yaml,sys; yaml.safe_load(open('$f'))" || echo "BAD: $f"; done; echo YAML-SWEEP-DONEExpected:YAML-SWEEP-DONEwith noBADlines.Run:
ansible-playbook playbooks/site.yml --syntax-check 2>&1 | tail -3(if ansible present) Expected: syntax OK (or documented skip when ansible absent).Self-review
sovrn_service_didalways fulldid:web:sovrn.at#svc_mail, bare DID derived viasplit('#')[0]in one place.S5 single-server smoke implemented (sovrn.at): 7 commits — non-secret vars, single-host inventory, didwellknown (static did.json, did:web:sovrn.at#svc_mail, bare-host endpoint, no secret), caddy single-vhost (did.json file_server ahead of reverse_proxy), goapp sovrn.toml (corrected oauth paths), foundationdb single-instance (empty backup_target, R2 var reserved), site.yml wiring. Verified: go tests green (incl. svcverifier), YAML sweep clean, ansible syntax-check OK. Operator smoke checklist: curl did.json / openid-configuration / oauth client-metadata, then login-to-IMAP loop.
Ansible dirs consolidated under deployment/ (inventory, group_vars, host_vars, roles, playbooks) with deployment/ansible.cfg (inventory + roles_path) so operators run from deployment/. docs/deployment.md layout/commands updated; in-file path refs fixed. Verified: YAML sweep clean, syntax-check OK from deployment/, go tests green.
Renamed Ansible role goapp -> sovrnd to match the binary (role dir, site.yml include, deployment.md tree). No stragglers; syntax-check OK.
S5 packaging + systemd Implementation Plan
Goal: Every process installed from a package/binary drop with a systemd unit; no role assumes its software is already on the host.
Architecture: New
commonrole owns users/dirs/prereqs and fails fast on non-amd64 (FoundationDB publishes amd64-only.debs). FDB installs from pinned GitHub-release.debs; Stalwart ships as the repo’s own Nix-built FDB-enabled binary copied by Ansible with anldd-gated rpath fix forlibfdb_c;sovrndis controller-built with the local Go toolchain and copied; Caddy comes from Cloudsmith (2.11.x). Stalwart first-boot bootstrap (theBootstrap/setJMAP sequence innix/scripts/serve-stalwart.sh) is ported touri-module tasks gated onmode==bootstrapplus a host sentinel.Tech Stack: Ansible (apt, systemd, uri modules), Debian trixie amd64, FoundationDB 7.3.x, Stalwart 0.16.19 (Nix,
foundationdbfeature), Go 1.26, Caddy 2.11.x (Cloudsmith).Locked decisions: amd64 server; Nix-built Stalwart copied; controller-built
sovrnd+sqlite3package on host; Cloudsmith Caddy (Debian tracker marks trixie 2.6.2-12 vulnerable to 2026 CVEs incl. a 9.1 FastCGI RCE with no trixie-security backport shown — none hit our minimal Caddyfile directly, but the TLS edge stays current); bootstrap ported to Ansible,mode==bootstraponly, sentinel-gated.Task 1:
commonrole + shared varsFiles: - Create:
deployment/roles/common/tasks/main.yml- Modify:deployment/group_vars/all/sovrn.yml(append block below) - Modify:deployment/playbooks/site.yml(common first)deployment/group_vars/all/sovrn.yml(Confirm
sovrn_fdb_versionagainstgithub.com/apple/foundationdb/releasespublished.debassets before first deploy; the role’sdpkg --infoassert catches metadata regressions.)deployment/roles/common/tasks/main.ymlcommonfirst indeployment/playbooks/site.yml(insert before sovrn_secrets include):Run:
python3 -c "import yaml; yaml.safe_load(open('deployment/roles/common/tasks/main.yml')); print('YAML OK')"(from repo root) Expected:YAML OKTask 2: FoundationDB install slice
Files: - Modify:
deployment/roles/foundationdb/tasks/main.yml(full rewrite below; keeps existing configure + smoke assert)deployment/roles/foundationdb/tasks/main.ymldeployment/roles/foundationdb/templates/foundationdb.conf.j2deployment/roles/foundationdb/handlers/main.ymlRun:
python3 -c "import yaml; yaml.safe_load(open('deployment/roles/foundationdb/tasks/main.yml')); print('YAML OK')"Expected:YAML OKTask 3: Stalwart install slice (Nix binary + unit + env)
Files: - Create:
deployment/roles/stalwart/tasks/install.yml- Create:deployment/roles/stalwart/templates/stalwart.service.j2- Create:deployment/roles/stalwart/templates/stalwart.env.j2- Modify:deployment/roles/stalwart/tasks/secret.yml(no change needed — env file renders from the same vault var) - Modify:deployment/playbooks/site.yml(include install.yml after secret.yml)deployment/roles/stalwart/templates/stalwart.env.j2deployment/roles/stalwart/templates/stalwart.service.j2deployment/roles/stalwart/tasks/install.ymldeployment/roles/stalwart/handlers/main.yml(create file):deployment/playbooks/site.ymlafter the secret.yml include:Run:
python3 -c "import yaml; d=yaml.safe_load(open('deployment/roles/stalwart/tasks/install.yml')); print('YAML OK')"Expected:YAML OKTask 4: Stalwart bootstrap port (mode==bootstrap, sentinel-gated)
Files: - Create:
deployment/roles/stalwart/tasks/bootstrap.yml- Modify:deployment/playbooks/site.yml(include after install.yml)Port of
nix/scripts/serve-stalwart.shFDB path, production differences: privileged mail ports (systemd +CAP_NET_BIND_SERVICE, no 1143⁄1587),allowPlainTextAuthstays default false (TLS enforced), tracer to{{ sovrn_stalwart_logdir }},requestTlsCertificate: false(Caddy terminates),generateDkimKeys: false(control plane provisions DKIM).deployment/roles/stalwart/tasks/bootstrap.ymldeployment/playbooks/site.ymlafter install.yml include:Run:
python3 -c "import yaml; yaml.safe_load(open('deployment/roles/stalwart/tasks/bootstrap.yml')); print('YAML OK')"Expected:YAML OKTask 5: Caddy install (Cloudsmith 2.11.x) + sovrnd build/install + rewire
Files: - Modify:
deployment/roles/caddy/tasks/main.yml(prepend install block) - Modify:deployment/roles/sovrnd/tasks/main.yml(prepend build/install block; keep datadir + sovrn.toml) - Create:deployment/roles/sovrnd/templates/sovrnd.service.j2- Create:deployment/roles/sovrnd/handlers/main.yml(move/keep Restart sovrnd; add daemon reload vialisten) - Modify:deployment/playbooks/site.yml(final order)deployment/roles/caddy/tasks/main.yml(Existing Caddyfile/enable tasks stay below unchanged.)
deployment/roles/sovrnd/templates/sovrnd.service.j2(
WorkingDirectory=/etc/sovrnbecauseLoadConfigreads./sovrn.tomlfrom cwd; the existing template already renders there.)deployment/roles/sovrnd/tasks/main.yml(Existing datadir + sovrn.toml render tasks stay below unchanged.)
deployment/playbooks/site.ymlorder (full file):Run:
python3 -c "import yaml; [yaml.safe_load(open(f)) for f in ['deployment/roles/caddy/tasks/main.yml','deployment/roles/sovrnd/tasks/main.yml','deployment/playbooks/site.yml']]; print('YAML OK')"Expected:YAML OKTask 6: Full verification
Run:
for f in $(jj file list 'deployment/**/*.yml' | grep -v templates); do python3 -c "import yaml,sys; yaml.safe_load(open('$f'))" || echo "BAD: $f"; done; echo YAML-SWEEP-DONEExpected:YAML-SWEEP-DONE, noBADlines.Run:
ansible-playbook playbooks/site.yml --syntax-check(fromdeployment/) Expected:playbook: playbooks/site.yml, no error.Run:
go build -trimpath -o /tmp/sovrnd-verify ./cmd/sovrnd && echo SOVRND-BUILD-OKExpected:SOVRND-BUILD-OKRun:
SOVRN_INTEGRATION=0 go test ./... 2>&1 | tail -3Expected: all packages ok.Self-review
dnsrole, keepalived/VIP, and R2 backup remain explicitly deferred.sovrn_fdb_versionvs published assets) is a documented verify step, not a placeholder.sovrn_*names identical across vars/tasks/templates/playbook;secret.ymlvault varsovrn_stalwart_recovery_passwordreused in env template + bootstrap auth;sovrn_fdb_cluster_filedefault reused in bootstrap datastore JSON.S5 packaging+systemd landed (5 commits): common role (amd64 assert, sovrn user, dirs, ufw mail/web/ssh), FDB pinned-GitHub-.deb install + arch-metadata assert + single-instance conf, Stalwart Nix-binary copy with patchelf rpath + ldd libfdb_c gate + env/unit + uri-ported bootstrap (prod listeners, sentinel, generated-admin 0600 record), Cloudsmith Caddy 2.11.x (trixie 2.6.2 carries 2026 CVE exposure incl. un-backported FastCGI RCE), sovrnd controller build + sqlite3 + unit (WorkingDirectory=/etc/sovrn), final site.yml order base->secrets->dataplane->control->edge. Verified: YAML sweep clean, ansible syntax OK, controller go build OK, full go test suite green. Review fixes during implementation: split dual-module tasks, controller-vs-host copy direction, JSON-string body for real booleans (render-checked). Deploy-time operator confirms: FDB 7.3.68 .deb assets published, then -e mode=bootstrap.
Secrets ceremony scripted: ansible.cfg vault convention (prompt every run, KeePass outside repo), stalwart bootstrap fetch-back to gitignored deployment/.local/, Just recipes provision-shared-secrets (gensecrets->render->encrypt, refuses overwrite) and persist-bootstrap-secrets (validate 32-hex, render host vault, encrypt, shred fetched copy), reminder message points at recipe, runbook in docs/deployment.md. Verified: both –dry-runs green (real keygen), vault encrypt/decrypt round-trip OK, YAML sweep clean, ansible syntax OK, go tests green. Remaining operator steps: run the two recipes, bootstrap, review, commit.
Added just bootstrap/update (default target sovrn.at) with prompt-free prechecks: ansible present, fleet vault encrypted (bootstrap) + host vault encrypted (update), target parsed from inventory/hosts.yml via python (ansible-inventory needs the vault password, so it cannot run before the prompt), non-interactive ssh is warn-only. Verified: bogus host fails closed, sovrn.at shared passes, update correctly demands bootstrap+persist first, syntax-check OK. Also found the shared vault already encrypted in-repo. Runbook steps now reference the recipes.
Fixed bootstrap failure: group_vars/hostvars were split from the inventory file, so Ansible never loaded them and every sovrn* var was undefined. Moved both inside deployment/inventory/ (the only layout Ansible auto-loads, documented in deployment.md). Also fixed ansible_architecture deprecation (ansible_facts[architecture]). Verified: scratch-inventory proof that adjacency loads group+host vars, syntax-check OK, prechecks re-verified against new paths, go tests green. Note: committed vault-shared.yml carries stale in-file path comments (ciphertext, refreshes on next rotation). Ready to re-run just bootstrap sovrn.at.
Fixed both bootstrap issues: (1) fetch+reminder moved from secret.yml to end of the bootstrap block — the old message claimed a bootstrapped credential before Stalwart was even installed; it now fires only after Bootstrap/set + sentinel, and the .local path mismatch (playbooks/.local vs deployment/.local) is fixed. (2) Nix build out of the playbook into just build-stalwart (with the missing –extra-experimental-features flags, out-link GC root at deployment/.local/stalwart-prod); install.yml asserts + copies instead, and the just prechecks fail fast when the binary is missing. go build stays in-Ansible (1 min, not worth the ceremony). Verified: YAML sweep, syntax-check, precheck gate message, nix eval resolves stalwart-0.16.19. Operator: run just build-stalwart now (up to an hour), then just bootstrap.
Ansible speedups: pipelining + ControlPersist multiplex + 24h jsonfile fact cache + deprecation silence in ansible.cfg; per-slice tags on all site.yml includes with ARGS passthrough on just bootstrap/update (e.g. –tags sovrnd,caddy, –start-at-task); apt cache_valid_time hourly; sovrnd controller build skipped when no .go newer than last binary (both branches logic-tested). copy tasks stay checksum-guarded. Documented retry shortcuts + Mitogen as next lever. Verified: –list-tags, syntax, prechecks, tests.
Fixed stalwart 203/EXEC: Nix binary pointed PT_INTERP at /nix/store (absent on Debian); host ldd passed because ldd uses the host loader, masking it. Diagnosed live via ssh (journal: No such file or directory on exec). Fix: patchelf –set-interpreter /lib64/ld-linux-x86-64.so.2 alongside the rpath fix, marker bumped to -v2 so affected hosts re-run it (copy checksum forces a fresh binary first), plus a patchelf –print-interpreter assert. Verified compatible: binary needs max GLIBC_2.39 (host 2.41) and GLIBCXX_3.4.30 (trixie gcc14 provides 3.4.34); just build-stalwart now gates both ceilings locally. Re-run just bootstrap sovrn.at (update mode would trip the config-exists assert since bootstrap never completed).
Fixed bootstrap 401: the recovery password is regenerated every bootstrap run, but the running process kept the previous run’s env — state=started never restarts an active/crash-looping unit and handler restarts flush only at play end, after bootstrap already authenticated. Added an unconditional daemon-reload restart as the first step of the bootstrap block so process and env file always agree. Re-run just bootstrap sovrn.at (clean retry: no config or sentinel was written, the failed run stopped at Bootstrap/get).
Fixed bootstrap undefined-var: sovrn_fdb_cluster_file lived in foundationdb/defaults, which only loads when that role executes — tag-skipped or reordered runs break stalwart bootstrap. Moved all three FDB topology vars to groupvars (always loaded) with a comment explaining why, deleted the role defaults file. Full audit: every sovrn* reference now resolves via group_vars or vault files, no cross-role defaults remain. Verified YAML sweep + syntax-check. Safe to re-run (full bootstrap or –tags stalwart-bootstrap).
Fixed bootstrap assert failure: fail_msg is templated even when the assertion passes, so referencing bootstrap_set.json.notUpdated crashed on success (key absent). Guarded with | default(‘none’). Notably this means the Bootstrap/set itself was ACCEPTED — the patch went through. Re-run just bootstrap sovrn.at to continue past it.
Fixed bootstrap admin-record failure: Bootstrap/set on a re-run returns no updated key (previous run’s set had already applied and created the admin), so the record step now only runs when updated is present and non-empty, with a debug note otherwise. Recovery-admin vault/env path is unaffected. Re-run just bootstrap sovrn.at.
Root-caused the missing config.json live: the accepted-looking Bootstrap/set was a silent no-op (defaults still in place, empty store dir), while an identical manual call applied instantly and wrote a correct FDB-pointer config. Payloads proven byte-equivalent by local render, so the difference was server-side timing/state across the crash-loop-era retries. Reset to a clean slate (deleted config.json, wiped FDB data dir, reconfigured single ssd) and added a read-back task asserting serverHostname/defaultDomain/clusterFile match — future silent no-ops now fail loudly with the actual singleton state. Re-run just bootstrap sovrn.at for a true first-apply (also exercises the record step properly this time).
Replaced the uri-task bootstrap with files/bootstrap-stalwart.sh: static env-driven shell (curl+jq, zero Jinja), Ansible keeps orchestration (restart, copy 0700, run with secret env under no_log, sentinel, fetch). Script covers health-wait, get, set with jq-built payload, admin record, config wait, read-back singleton verification, and the 5 prod listeners. Tested end-to-end against a stub JMAP server: BOOTSTRAP-APPLIED exit 0, real JSON booleans asserted server-side, creds file correct, re-run prints BOOTSTRAP-PRESENT. The stub test caught one real bug pre-deploy (extra brace in listener JSON, transcribed from the deeper set shape). Re-run just bootstrap sovrn.at.
Hardened the credential lifecycle while diagnosing: every bootstrap run regenerated the recovery password, which risks lockout if Stalwart pins the first value DB-side (normal-mode recovery auth verified working with the current value, but rotation was never proven safe). secret.yml now generates only on truly fresh hosts and otherwise reuses the host file via slurp; fetch/reminder gate on needs_persist instead of generated. Next run reuses the known-good credential, creates the 2 missing listeners (25/465/993 already up — partial state from the earlier runs), then sentinel+fetch. Dropped no_log on the script task so stderr is visible on failure.
Implemented wipe-on-bootstrap + listener reconcile + tooling decision. (1) mode=bootstrap now destroys (rm, no move-aside): FDB data+cluster, stalwart datadir/config/sentinel/creds, stale controller fetch; forced reconfigure; update/recover never wipe. Every bootstrap is a true first-apply. (2) Listeners phase reconciles Stalwart’s auto-created defaults: keeps smtp/submissions/imaps/http/sieve, creates submission/587+imap/143, destroys https/443 (Caddy conflict) + pop3s/995, final assert with diff. Sieve stays up but firewalled (4190 absent from ufw allows + code comment). (3) Tooling: Ansible stays thin (files/services only), all logic in stub-tested shell, no pyinfra. Caught pre-deploy via stub: envelope-forgetting jq checks + two extra-brace payloads; shellcheck clean. Re-run just bootstrap sovrn.at.
Fixed cluster-file timeout with live log proof: fdbserver NEVER creates the cluster file — without one it exits 10 and fdbmonitor respawns it every 61s forever (the phantom second fdbmonitor was just a child caught mid-respawn). My dev-container assumption was wrong; there the seed-connection-string did the job. The bootstrap wipe now authors a fresh sovrn:@127.0.0.1:4500 cluster file itself (stable afterwards — only bootstrap writes it), and the stale wait-for-a-file-nobody-creates is gone. Reordered role so install precedes wipe (fresh hosts have no /etc/foundationdb yet). Re-run just bootstrap sovrn.at.
Implemented per plan: (1) central bootstrap stop-all role, dead-first in site.yml (fresh boxes: all four stops are clean no-ops); scattered in-role stops removed. (2) Destroy goes through set+destroy (doc-confirmed RFC 8620, no /destroy method exists). (3) Patchelf creates-marker replaced by copy-changed-conditional + always-on asserts (a Nix rev bump can no longer silently skip the loader fix). (4) Listener reconcile proven against seeded-defaults stub: keep 5, create 587+143, destroy https+pop3s, final assert, converged re-run exit 0; shellcheck + syntax clean. Run just bootstrap sovrn.at for the true first-apply.
Fixed 502: sovrnd binary exists but has Nix PT_INTERP (controller Go is a Nix toolchain + cgo sqlite) — same 203/EXEC class as Stalwart, never caught because the binary was built but never executed. Verified compatible (needs only glibc libs, max GLIBC_2.34 vs host 2.41; sqlite statically linked). Both binaries now share one pattern: fix gated on patchelf –print-interpreter check itself (not copy-changed/markers), so it self-heals already-deployed hosts and survives rev bumps; always-on interpreter + ldd asserts. Just update sovrn.at with no manual steps — the check gate fixes the live binary in place.
Option A implemented: just check-fdb-alignment (nixpkgs client vs pinned .deb, major.minor compare tolerant of nixpkgs suffix noise, fails on skew; wired into build-stalwart), sovrnd libc-ceiling gate in-role on every build (same trixie GLIBC_2.41 pattern), coupling note in deployment.md (also replaced a stale build note). Verified: live gate passes (7.3.68⁄7.3.68), deliberate-skew dry run fails correctly, ceiling logic passes/rejects correctly, syntax + tests green.
Fixed persist rejection: fetched value is valid 32-char hex but mixed-case — Ansible’s password lookup draws from Python hexdigits (0-9a-fA-F), while the persist validator only accepted lowercase. Widened the regex with a comment explaining why; verified via dry-run against the actual fetched file. Deliberately did NOT constrain generation to lowercase instead — that would rotate the live DB-pinned credential. Operator: re-run just persist-bootstrap-secrets sovrn.at (needs your vault password).
Fixed 502: sovrn.toml.j2 nested datadir+env under [server], but the Go struct maps them top-level (viper silently ignores the misplaced keys) — sovrnd ran on default datadir data/dev and died creating it under unwritable /etc/sovrn. This also silently forced env=development (NetProber off). Fixed template + added comment guard; verified by rendering with prod values and asserting top-level placement plus every Validate() requirement via tomllib. Also reordered role so datadir+config land before enable/start (was reversed; restart-loop masked it). Fixed live box with byte-exact template output: sovrnd running, homepage 303 (login redirect), next update is no-change on that task.
Smoke IMAP-SSL root cause + minimal fix (2026-09-11)
Symptom: iPhone to
mx1.sovrn.atfailsCannot Connect Using SSLon 993;try without SSLsucceeds. Domaintest.kilimanjaro.ioactive, DNS (MX/SPF/DKIM + mx1 A) correct, app-password auth works over plaintext.Root cause: Stalwart serves IMAP/SMTP TLS itself with
useTls=trueon all listeners, but bootstrap setsrequestTlsCertificate=false(“Caddy terminates”) and noCertificate/AcmeProviderwas ever provisioned. Caddy only terminates HTTP forsovrn.at -> sovrnd:8081, so port 993 had no valid cert to present. Plaintext 143 worked, isolating the failure to the TLS layer.Fix (this change, minimal):
Caddyfile.j2now proxies ONLY/.well-known/acme-challenge/*to Stalwart:8080(bothsovrn.atand{{ sovrn_mail_hostname }}sites; mx1 site 404s everything else), so Stalwart’s own ACME Http01 can issue formx1.sovrn.at. Management (/jmap,/admin,/healthz) is never proxied — stays localhost-only + firewalled. Next: create staging->prod AcmeProvider + Domain Automatic with explicitSANs=["mx1.sovrn.at"], then re-test iPhone SSL.Verified: template renders, YAML sweep clean, ansible
--syntax-checkOK,go test ./...green.Deferred: single-appview/multi-mail architecture (dedicated
imap./smtp.failover names, shared-FDB join vs greenfield bootstrap, sovrnd DB: Litestream vs Postgres vs FDB) tracked in new issue 6ca5959.ACME in bootstrap (no more one-off) — ready for rebuild test
Stalwart cert provisioning is now first-boot automation, per request.
What changed (uncommitted, 6 files): -
roles/stalwart/files/bootstrap-stalwart.sh: newacmephase — ensuresAcmeProvider letsencrypt(Http01, LE prod default, contactpostmaster@<domain>), setsDomain <mail-domain>tocertificateManagement: Automaticwith explicitSANs=[mx-host](update-or-create, re-runs converge), then pollsCertificate/getup to 600s for a covering valid cert. PrintsCERT-PRESENT/CERT-ISSUED; fails loudly with AcmeRenewal/log pointers on timeout. -roles/stalwart/tasks/bootstrap.yml: runs theacmephase after listeners (staging-directory override documented for test rebuilds). -inventory/group_vars/all/sovrn.yml:sovrn_acme_directory(prod) +sovrn_acme_contact. -playbooks/site.yml: caddy now runs BEFORE stalwart bootstrap — the acme phase validates via the Caddy challenge proxy, so :80 must answer first. sovrnd/didwellknown ordering untouched. -roles/caddy/templates/Caddyfile.j2: mx1 site is nowhttp://-only (no Caddy-managed cert → no challenge-solver conflict); challenge path proxied on both sites, management never proxied. -docs/deployment.md: DNS step names the mx1 A explicitly; bootstrap step documents the ACME phase + staging override.No clobber risk from sovrnd:
internal/domainonly writesname/isEnabled/dkimManagement/memberTenantId— nevercertificateManagement.Verified: shellcheck clean; stub-JMAP end-to-end (fresh create →
CERT-ISSUED, rerun →CERT-PRESENT, pre-existing Manual domain → update →CERT-ISSUED, timeout → exit 1); Jinja renders; YAML sweep clean; ansible--syntax-checkOK;go test ./...green.To test live: blow away smoke host,
just build-stalwartif needed,just bootstrap sovrn.at(add-e sovrn_acme_directory=<staging>to dodge LE duplicate-cert limits on repeated rebuilds), thenopenssl s_client -connect mx1.sovrn.at:993+ iPhone SSL add.Inbound bounce (Fastmail -> [email protected]) root-caused: Fastmail-side local routing, not our server
Bounce:
550 5.1.1 ... User unknown in virtual mailbox tablefrommailuser.phl.internal(Fastmail-internal LMTP hop).Evidence (all checked live): - Public DNS correct:
test.kilimanjaro.io MX 10 mx1.sovrn.at, SPF present,mx1.sovrn.at A 2.29.20.216. Parentkilimanjaro.ioMX is Fastmail (messagingengine.com). -mx1:25/143/587/993all bound by stalwart; ufw inactive (no firewall). - Stalwart logs contain ZERO lines for kilimanjaro/messagingengine/fastmail and zero rejects — Fastmail never connected to mx1. -x:Domain/get:test.kilimanjaro.iopresent andisEnabled: true; account works over IMAP. Our inbound path is provisioned. - Ports 80/443/587/993 reachable remotely; only :25 times out from the dev box (dev-egress filtering, not the server — listener is up).Conclusion: Fastmail decided the recipient locally (parent domain is Fastmail-hosted, so the subdomain resolves in its own virtual table) and bounced without ever consulting public MX. Nothing to fix on sovrn.at for this incident. Next: re-test from a non-Fastmail sender (Gmail) to prove end-to-end inbound; adjust Fastmail domain/routing settings for the subdomain if Fastmail must be able to send to it.
Unrelated observations: live box still serves no TLS cert (
tls.no-certificates-availablein today’s log — pending ACME-bootstrap rebuild; harmless for port-25 inbound which falls back to plaintext).S5 scope amendment (cell architecture, bug 75966cc — locked 2026-09-13)
S1–S4 stand as completed. S5 is rescoped; S6 housekeeping now also covers bugs 6ca5959 + 16df68d (both closed as superseded — see their comments).
S5 old scope (struck):
common/stalwart/foundationdb/caddy/goapp/dnsroles, Stalwart-FDB packaging, Caddy TLS + MX smoke on shared-FDB active/passive + VIP.S5 new scope: per-cell roles on SQLite everywhere —
common/stalwart(sqlite)/zds/caddy/sovrnd/litestream/rclone/dns(T2/T3/T4), fresh bootstrap + converge green with no FDB artifacts (grep-clean), smoke covering control plane + Stalwart + ZDS behind Caddy with real domain + MX. Cell-model ADR + runbooks land first (T9); floating-IP role follows (T10).Complete: S1-S5 achieved — real service-auth JWT + authbroker/tokenissuer/appwd wiring, app-view UI skeleton, Ansible roles/playbooks and a live deployment (mx99 cell + infra metrics box), Caddy TLS + MX. S6 (tracker housekeeping) is being executed now via the consolidation into epic 516fcde. Closing.