[C2] stalwartsync: Registry-client spike — schema, object lifecycle, two-phase reload
closedGoal
Prove the control plane ↔ Stalwart integration works exactly as designed before any product features: a Go package internal/stalwartsync that authenticates to Stalwart and performs the full provisioning object lifecycle over Registry/set|get|query on POST /jmap, verified against the C1 harness.
Context: docs/05-stalwart-integration.md (§1 API surface, §2 mechanics, §3 credentials, §6 ops), docs/03-provisioning.md §6.
Tasks
- [ ] JMAP envelope client (request/response, error mapping incl.
trcerror shapes) - [ ] Schema discovery: resolve
/api/schema/{sha256}redirect → download gzipped JSON → codegen or runtime typing decision (ADR note) - [ ] Auth as service account (basic/bearer per what C1 bootstrap provisions)
- [ ] Domain lifecycle:
Registry/set Domaincreate → get → update → delete; verify DKIM DNS-publication task fires - [ ] Account lifecycle:
Registry/set Accountwith quotas +EmailAliaslist → get → alias add/remove → delete → poll async destruction task (TaskDestroyAccount) to completion - [ ]
ApplySettings()helper: settings object set + explicitAction::ReloadSettings, verifying hot reload took effect - [ ] Integration tests in CI against C1 (dockerised)
Acceptance criteria
- Full lifecycle tests green in CI against real container
- Replaying every write twice produces identical end state (idempotency proof for crash-retry safety)
- Masked-password path proven: updating account fields does NOT require resending secrets
Risks this should surface
- Registry API error semantics/edge cases not visible from reading source
- Async destruction polling semantics
- Whether schema drift between Stalwart versions breaks our client (feed the CI schema-diff job decision)
4 Comments
Provisioning packages:
internal/jmap+ record-kind lifecycle packagesGoal: Prove control-plane ↔ Stalwart integration: a pure JMAP plumbing package (
internal/jmap), a Stalwart-Registry semantics layer (internal/registry), and one package per provisioning record kind (tenant,domain,account,settings) whose public API reads like the domain actions they perform — ready to be driven by AT proto record handlers later.Architecture: Three layers, strict downward deps only:
Every write path is exposed as both strict
Create(typedErrAlreadyExists) and idempotentEnsure(query→diff→set-or-noop) — crash-retry safety per acceptance criteria. Integration tests env-gated against the C1 harness (devenv up).Tech stack: Go 1.26 stdlib only. Libraries evaluated and rejected (recorded in doc.go):
rockorager/go-jmap(mature but session-centric, no Registry methods),pr0ton11/jmap-go(hasstalwartsub-package but v0.x/single-maintainer/zero importers, wrong ergonomics).Decisions locked with owner (2026-08-25): - hostname = mail domain being managed; connection via explicit
(ctx, c *jmap.Client, ...)params, no globals - Both strict Create and idempotent Ensure exported - Tenancy required (decision A):domain.Create(ctx, c, name, tenantID)— tenant assignment mandatory & positional; infra/service zones live under a designatedplatformtenant (tenant.PlatformTenantName), never in the global namespace. Account likewise takes required tenantID (server FK-cross-checks account↔domain agreement) - Schema typing = hand-typed structs + pinned/api/schemahash (ADR 0003); deliverable test-only (no CLI); IMAP-wire login stays in C5Verified ground truth (Stalwart @ rev 2add611e + C1 comments c7cfd93 / 345b461)
x:<ObjectType>/<get\|set\|query>under capabilityurn:stalwart:jmap; envelope fieldmethodCalls;accountId:"a"accepted["error",{type,description},callId]; types:forbidden invalidArguments serverFail unknownMethod unsupportedFilter requestTooLarge stateMismatch anchorNotFound accountNotFound …notCreated/notUpdated/notDestroyedmaps{type, description?, properties?}/data/service-secret(usersovrn-admin)x:AppPassword/setscoped by targetaccountId; server generates secret, returned once ascreated.<id>.secret"****"preserves stored secret on credential updatedkimManagement:{@type:"Automatic"}schedules DKIM task; DNS publication additionally requires aDnsServerobject (absent in dev)Action::ReloadSettings=x:Action/setcreate{"@type":"ReloadSettings"}; actions reject update/destroyTaskDestroyAccount(OSS: immediate); completion must be polledCannotDeleteLinked⇒ notDestroyedTenant{name,…}free-form label, top-level;Domain.member_tenant_id: Option<Id>+Account.member_tenant_id; FK enforcement rejects nonexistent tenant refs AND account→domain tenant mismatch at write timeforeign_key(Tenant,…)GET /api/schema/{x}(auth required) redirects to hash URL; pin @ rev:25gnm1_xB9bPk-puX9VTFMP1W5V-1muUWuK964-_uEw@typetags: Account"User", DKIM/DNS mgmt"Automatic"/"Manual"; quota keys enum strings (maxAccounts maxDiskQuota maxEmails …)File structure & API contract
Import graph:
jmap ← registry ← {tenant,domain,account,settings}; story test imports all four record packages. Future AT proto mapper imports record packages only.Tasks
Task 1: Scaffold
go.mod(module sovrn, go 1.26); devenv.nix +=pkgs.go pkgs.golangci-lint; doc.go files stating package contracts + rejected-library note.go build ./...; commitfeat: go module scaffold.Task 2: internal/jmap — envelope + typed errors (TDD)
feat(jmap): envelope client with generic Send and typed errors.Task 3: internal/registry — semantics + config (TDD)
feat(registry): stalwart registry ops, config, schema pin.Task 4: internal/tenant + internal/domain (TDD)
feat(tenant,domain): lifecycle, required tenancy, Create/Ensure duality.Task 5: internal/account + password (TDD)
{"@type":"User","name":"dave","domainId","memberTenantId","aliases":[…],"quotas":{…}}; Ensure ErrAssigned semantics; IssuePassword asserts x:AppPassword/set shape + extracts created.apX.secret; UpdateMasked emits credentials [{credentialId,secret:“****”}]; AwaitDestroy polls until notFound.feat(account): lifecycle, masked updates, app passwords, async destroy.Task 6: internal/settings (TDD)
{"create":{"act0":{"@type":"ReloadSettings"}}}; fails if either phase errors. Property discovered live from x:Jmap/get during Task 8.feat(settings): two-phase ApplySettings helper.Task 7: ADRs
docs(adr): 0003 schema typing, 0004 required domain tenancy.Task 8: Integration journey (the spike proper)
Skip guard: no STALWARTSYNC_URL ⇒ t.Skip. - [ ]
TestIntegrationProvisionUserStory, literal names, rerun-safe via Ensure: 1. Setup: registry.VerifySchema; tenant.Ensure(PlatformTenantName) 2. tenant.Ensure(“atproto”, quotas) → id; replay unchanged 3. domain.Ensure(“atproto.test”, t.ID) → changed; poll x:Task/get for DkimManagement task (log observed); replay → changed=false 4. Negative: domain.Ensure(“atproto.test”, otherTenant) → ErrAssigned; domain.Create(“fresh.test”,“bogus”) → ErrTenantNotFound (wire shape recorded); premature tenant.Destroy(atproto) → typed notDestroyed/CannotDeleteLinked 5. account.Ensure(“atproto.test”,“dave”,t.ID, quotas, alias) → replay unchanged 6. Anonymous GET /api/discover/[email protected] resolves 7. account.IssuePassword(daveID,“imap-device”,24h) → Basic auth AS [email protected] via jmap Core/echo succeeds (IMAP wire = C5) 8. Masked-path subtest: scratch account WITH Password credential → UpdateMasked description → old secret still authenticates 9. settings.Apply on benign JMAP knob → value reflected; re-apply noop 10. Full replay of steps 2–5 → all changed=false (crash-retry idempotency proof) 11. Teardown order enforced: account.Destroy+AwaitDestroy (<30s) → domain.Delete → tenant.Destroy; discover stops resolving - [ ] Findings recorded whichever way they fall: Tenant-on-OSS reality; query filter operator names accepted; DKIM task visibility window; app-password-auth-without-primary-password behavior; singleton-id conventions; exact invalidForeignKey SetError wire type; platform-tenant behaviour. - [ ] Run: devenv up, then STALWARTSYNC_SECRET_FILE=data/dev/stalwart/service-secret go test ./internal/integration -v. Committest(integration): signup story journey.Task 9: Close-out verification
Progress checkpoint — Tasks 1–4 complete, paused for owner review — 2026-08-25
Plan (comment 264fd9c) executed through Task 4 via subagent-driven development; each task passed spec-compliance + code-quality review gates.
Commits (jj, oldest first)
git.kilimanjaro.io/sovrn; devenv gains go + golangci-lint; 6 package-contract doc.go files)API now real
jmap.Send[T]envelope client →registry.Get/Query/Set/Destroy/SetResult→tenant.Create/Ensure/Delete,domain.Create/Ensure/AssignTenant/ListForTenant/Delete. Decision A enforced: tenancy required & positional;ErrTenantRequired, precheckErrTenantNotFound, conflictAssignedError{Current}with provably-zero writes on mismatch.Design decisions made during execution
{}(RFC 8620 conformance); Error.HTTPStatus added for future retry policyQueued for Task 8 live verification
primaryKeyViolation/cannotDeleteLinked wire casing; tenant-on-OSS create; DKIM task visibility window; quotas merge-vs-replace server behavior; memberTenantId query filter under top-level token; app-password auth without primary password; singleton-id conventions.
Remaining: Tasks 5–9 (account+password, settings, ADRs 0003/0004, integration journey, close-out)
Implementation complete — full journey green against live C1 harness (2026-08-25)
Plan (comment 264fd9c) executed end-to-end via subagent-driven development; every task passed spec-compliance + code-quality review gates. Mid-flight owner review (bugs d73c3db/19e419e) merged
internal/jmap+internal/registryintointernal/stalwart(comments df7b3cc/1596ef4).Commits (oldest first)
qulpwsty scaffold → rxvwtrwz selfci CI → puplsvow feat(jmap) → svskvltx feat(registry) → zzwlqknk feat(tenant,domain) → xzmvnpzr refactor(stalwart): merge → oomwrwow feat(account) → zylwxsns feat(settings) → xospuolq docs(adr): 0003+0004 → mlmqvoqz test(integration): signup story journey.
Acceptance criteria — all met (live,
go test -race ./internal/integration -v)Surprises (the point of Phase 0) — plan’s source-reading was wrong in 8 places
[{attribute,operator,value}]{"name": v}, ordered ops{"nameIs<Op>": v}; array → HTTP 400 notRequestemailAddressis server-computed (unindexed){"0":{…}}; array → invalidPatchcannotDeleteLinkedobjectIsLinked(+objectId/linkedObjects)totalcalculateTotal:true"****"in account credentials"****"belongs inx:AccountPassword/setonly/api/discover/{email}resolves per-addressConfirmed-correct from source: app-password = standalone
x:AppPassword/setwith targetaccountId, server-generated one-time secret (works via JMAP basic-auth);Action::ReloadSettings={"@type":"ReloadSettings"}; settings singleton id is literal"singleton"; asyncTaskDestroyAccount(account destroy is read-synchronous in practice); DKIM auto-management materializesDkimSignatureobjects +DkimManagementtasks; destroy ordering must remove linkedDkimSignaturebefore the domain.Follow-ups worth filing (not blocking this spike)
calculateTotalwhen tenant count grows.domain.ListForTenantpagination guard is now live (total populated) but does not yet paginate.Failed“Invalid foreign key” task in harness).Left open for owner review.
Follow-up issues filed: pagination/tenant-enumeration → 9d84518; DKIM-task orphan → cf71374.