Postmaster identity creation on the new instance

closed
#eb70bc9 opened by agent Sep 20

Deliverable

Ensure the per-domain postmaster identity (postmaster.at.<domain>) is created against the PDS instance provisioned by Task 1 and is a durable per-domain authority: invite via the cell admin token → createAccount with the shared per-cell password → createSession on the ErrExists retry path → author the public at.sovrn.domain.route record last.

This is mostly implemented in the saga (internal/appview/provision.go) and internal/postmaster; the work here is hardening and verification, not a new record type.

Scope

  • Runs against the instance created in Task 1 (not a pre-existing one).
  • Idempotent: retries reuse the account via ErrExists/session.
  • The route record is the on-protocol “which cell hosts this domain” claim and a future extension point; the pri-20 relay does not consume it.

Non-goals

  • No KMS keys, no #atproto_space verification key / #atproto_space_host service entry, no space repo, no at.sovrn.domain.detail / at.sovrn.mail.account writes (deferred to Spaces Stage B).

Acceptance

  • After a successful saga the postmaster account exists on the domain’s PDS and owns the route record.
  • Retries after a crash reuse the account and converge without duplicate DIDs.

2 Comments

agent e7be790 Sep 21

Implementation Plan: Postmaster identity on a newly provisioned PDS

Scope decision (from design review)

  • IDN (non-ASCII) hosted domains: reject with 4xx before any side effects.
  • Unit-level verification only; live ZDS smoke stays with bug 28c7d1f.
  • Ownership read-after-write stays with the reconciler (bug 9f75370).

Current state (already implemented — do not rebuild)

  • Saga: internal/appview/provision.go:348-469 — EnsureProvisioned → invite (CreateInvite) → CreateAccount → ErrExists/CreateSession retry → PutRouteRecord → DB row last, with reverse compensations gated by postmasterCreated/dkimCreated/domainChanged.
  • Client + records: internal/postmaster/postmaster.go, route.go (validate, retire, VerifyAuthor), route_read.go (GetRouteRecord).
  • Wiring: router.go:481-556 sagaWiring builds PostmasterDeps from the cell admin token + shared per-cell Postmaster.PasswordFile, gated on cfg.PDS.Supervision. PDS handle domains come from HandleDomainsForDomain = .<origin>,.at.<domain> (internal/pdslifecycle/env.go:194), which already matches postmaster.at.<domain>.
  • Tests: provision_saga_test.go (order, reuse, per-step faults, compensation, provenance, race backstop), postmaster_test.go (body shapes, ErrExists, delete-absent).

Gaps

  1. No ASCII/punycode handling. The saga builds postmaster.at.<domain> and the route domain from the raw (lowercased) name (provision.go:372-373), while the PDS serves the IDNA-normalized suffix and ValidateRoute is ASCII-only (route.go:44). An IDN hosted domain would create the Stalwart domain + PDS instance, then fail at CreateAccount/PutRouteRecord — after side effects, and as a retryable 500.
  2. No pre-validation of the handle. internal/pdslifecycle/handles.go already has ValidateAccountLabel/ValidateVanityHandle; the saga never uses them.
  3. Error classification. Postmaster terminal failures (invalid handle, disallowed TLD, validation) surface as generic 500 (domain_create.go:52-55); IsUserInputError (provision.go:79) does not know them → infinite retry.
  4. Test gap at the saga seam. Tests assert call order but not the createAccount body (handle/email/shared password/inviteCode) nor that EnsureProvisioned receives the normalized domain.
  5. No live proof. postmaster_test.go only pins endpoints against ZDS source comments; scripts/smoke-zds.sh exercises createAccount but not the postmaster sequence. (Deferred to 28c7d1f.)

Task 1 — Reject IDN domains before mutation

  • internal/appview/provision.go: after domainName is lowercased/trimmed (provision.go:194) and after the duplicate short-circuit (provision.go:209-220), reject any name containing non-ASCII bytes with a new sentinel ErrInvalidDomain. Placement guarantees zero Stalwart/DKIM/PDS/postmaster calls, and preserves idempotent duplicate behavior for existing rows.
  • Add ErrInvalidDomain to IsUserInputError (provision.go:79) so the UI path classifies it too.
  • internal/appview/domain_create.go:47-56: map ErrInvalidDomain → 400 InvalidRequest (alongside ErrTenantNotFound).

Task 2 — Classify postmaster terminal errors

  • Terminal set stays minimal and typed: the new ErrInvalidDomain (Task 1). ErrInvalidRoute/author errors already surface only where the reconciler consumes them.
  • internal/appview/ui/handler.go already routes unknown create errors via IsUserInputError (handler.go:319-332); add/extend a UI test asserting a bad domain renders a 4xx form error, not a 500.

Task 3 — Strengthen the saga unit tests

In internal/appview/provision_saga_test.go, using the existing sagaZDS/sagaFixture:

  • Capture and assert the createAccount body for the happy path: handle postmaster.at.b.example, email [email protected], shared Password, and the inviteCode equal to the minted invite (sagaZDS currently only records call order — add a gotCreate capture).
  • Assert fakePDS.EnsureProvisioned receives the normalized domain (record the arg).
  • Add “crash between account and putRecord” convergence: pre-existing account + missing route → one DID, PutRouteRecord overwrite, no second account.
  • Add IDN rejection: "münchen.de" → ErrInvalidDomain, and assert no stalwart:/pds:/zds: calls.
  • Add a domain_create handler test mapping ErrInvalidDomain → 400.

Task 4 — Docs/comments

  • Note the ASCII-only invariant and where it is enforced in the ProvisionDomain doc comment and the internal/postmaster/route.go:44 cross-reference.

Verification

  • go test ./internal/postmaster/... ./internal/appview/... ./internal/pdslifecycle/...
  • just test (SOVRN_INTEGRATION=0 go test ./...)
  • Baseline confirmed: those packages currently pass.

Non-goals (unchanged)

No KMS/#atproto_space, no space repo, no at.sovrn.domain.detail/at.sovrn.mail.account writes; the pri-20 relay does not consume the route record.

Acceptance mapping

  • Account exists on the domain’s PDS and owns the route record → implemented (provision.go:434) and pinned by TestProvisionDomainSagaHappyPath.
  • Retries reuse the account without duplicate DIDs → implemented (provision.go:392-407) and pinned by TestProvisionDomainSagaReusesExistingPostmaster; Task 3 strengthens the body/domain assertions.
  • New in this bug: IDN input fails cleanly as a 4xx with no side effects, instead of a partially-provisioned domain and a retryable 500.
agent e5bb7c0 Sep 21

Implementation (Tasks 1-4) landed in the working copy, uncommitted:

  • provision.go: new ErrInvalidDomain sentinel + isASCII guard. Non-ASCII domains are rejected after the duplicate short-circuit and before ANY Stalwart/DKIM/PDS/postmaster mutation; ErrInvalidDomain added to IsUserInputError.
  • domain_create.go: ErrInvalidDomain -> 400 InvalidRequest.
  • ui/handler.go path already 422s user-input errors; new TestCreateDomainIDNHXReturns422Fragment pins it.
  • Tests: happy path now asserts the createAccount body (handle postmaster.at., email postmaster@, shared password, minted inviteCode) and that EnsureProvisioned receives the normalized domain; reuse test asserts exactly one createAccount + one createSession, no teardown, row persisted; new IDN rejection test asserts ErrInvalidDomain with zero infra calls and no DB row. fakePDS records the domain; sagaZDS captures the createAccount body.
  • Docs: ProvisionDomain and postmaster/route.go cross-reference the ASCII-only invariant.

Verification: gofmt clean; go vet clean; go test ./internal/appview/… ./internal/postmaster/… ./internal/pdslifecycle/… and SOVRN_INTEGRATION=0 go test ./… all green. No live ZDS smoke (deferred to 28c7d1f) and no read-after-write (reconciler 9f75370), per plan.