Extends the workspace-level /CLAUDE.md. All global rules apply.
Rules here are yarilo-specific and take precedence where they overlap.
app/
yarilo/ - main binary (entry point only, no business logic)
smoketest/ - post-deploy smoke test binary
internal/
auth/ - passdb chain, yarilo-auth UNIX socket protocol
backend/ - single-node wiring of all components
cluster/
ring/ - consistent hashing ring (MD5, 100 vhosts)
proto/ - yarilo-director TAB-delimited wire protocol
imap/ - IMAP server (go-imap/v2 + extensions)
storage/
mailbox/ - MailboxBackend implementations (maildir, dbox, …)
index/ - IndexBackend implementations (fileindex, sqlite, …)
telemetry/ - /healthz, /readyz, /metrics
pkg/
mailbox/ - MailboxBackend + IndexBackend interfaces, core types
config/ - YAML config via koanf
helm/ - Helm chart (Chart.yaml, values.yaml, templates/)
docker/ - Dockerfile
yarilo's infrastructure architecture is defined by these documents and diagrams in docs/. They are the source of truth for every decision about deployment, scaling, HA, and cross-component coordination:
- DEPLOYMENT - deployment topology, sizing (per pod, per tag), HA strategy, sharding via tags, and the rationale behind each decision
- docs/yarilo_director.svg - director deployment: login proxies, a 3-replica director Deployment with peer-sync, backend-lease (self-registration + heartbeat #776, replacing the monitor sidecars), ring routing to backend tags
- docs/yarilo_backend.svg - backend deployment (per tag): ONE co-located StatefulSet whose pod carries every protocol container (imap/pop3/submission/lmtp/managesieve) plus
yarilo-ftsand theyarilo-backend-regsidecar on a shared IP;yarilo-locks,backend-apiandquota-statusare separate deployments; one shared NFS PV (RWX) - docs/yarilo_standalone.svg - standalone deployment: the full stack (login + sessions + auth + warden + embedded
yarilo-locks+ storage) for self-contained installations without a director
How to use them:
- Any change to the infrastructure approach (new pods, a different scaling model, new services, changed coordination) starts by updating these diagrams and DEPLOYMENT.md, not by writing code.
- When code contradicts a diagram, either the code is corrected to match or the diagram is updated with a stated rationale.
- When planning a feature with infrastructure consequences, check the diagrams first, discuss the change, then update the document.
Key architectural decisions, as recorded in the diagrams:
- ONE co-located StatefulSet per backend deployment - the pod carries every protocol container (imap/pop3/submission/lmtp/managesieve) plus
yarilo-fts(fts_addr=localhost) and theyarilo-backend-regsidecar on a shared IP. This restores the reference invariant that one mail host owns all of a user's per-user resources, which is what makes a single ring and a single userDir correct (#788); co-locating fts gives a single writer per user index for free (#675/#676). Independent per-protocol scaling is given up deliberately in exchange for routing coherence - consistent hashing cannot hold "one user, one pod" across separate pools yarilo-authandyarilo-wardenare shared services (two Deployments), one deployment per installationyarilo-locks- single abstraction for cross-process write coordination. All k8s deployments (standalone and backend) useremotemode - its own Deployment behind a ClusterIP Service, mTLS TCP:9104, Redis-backed state.embeddedmode (in-memory + Unix socket) is reserved for unit tests and non-k8s CLI runs; it is never the production default because Unix sockets cannot cross pods, which breaks anyreplicaCount > 1. In-process goroutine concurrency stays onsync.Mutexas a two-tier fast-path.- One NFS PV (RWX) per tag, shared by every co-located pod within that tag; a
tagis an NFS shard, NOT a protocol - Director is a 3-replica Deployment with peer-sync, ONE ring and one userDir - a single pod IP per user serves every protocol, and the login proxy overrides the port. The ring organizes itself (#750): a pod joins through the headless
-director-ringService as its seed, so no stable pod identities are needed - Sticky routing is per user, not per protocol: a user is pinned to one co-located pod for every protocol, and cross-pod coordination goes through
yarilo-locks - TLS termination and passdb happen at the director; userdb happens at the backend via the shared
yarilo-auth
ARCHITECTURE.md is the single source of truth for code-level architecture. Read it before any implementation work. Every decision about processes, UIDs, IPC, storage is defined there. Code that contradicts ARCHITECTURE.md is wrong - fix the code, not the document.
For the infrastructure and deployment layer, see https://doc.yarilomail.org/latest/installation/deployment.html and the SVG diagrams above.
Key rules derived from ARCHITECTURE.md:
- Multi-binary, multi-process. Each component is a separate compiled binary in
app/. Never create a single binary with mode flags. Never put two components in onemain.go. - Config-not-binary. Every deployment / scaling / topology change goes through configuration
only (
yarilo.yamlor Helmvalues.yaml). The compiled binaries do not change between shapes: oneyarilo-locksartefact serves embedded mode (tests / dev CLI) and remote mode (k8s prod); oneyarilo-imap(and each session binary) serves single-node standalone and sharded backend. No build tags (//go:build standalone), no compile-time switches, no runtimeif isStandalone()branches in code that gate anything beyond reading a config value. ScalingreplicaCount: 1 → N, switching bundled Redis ↔ external Redis, migrating standalone → backend with director - all values.yaml changes, never code changes. Backends behind interfaces are fine; code-level branching on deployment shape is not. exec.Commandonly - neverfork(). Go runtime + fork = undefined behavior.- Privilege drop via
SysProcAttr.Credentialonly.syscall.Setuid()does not work in multi-threaded Go. Credential is set at process start, before Go runtime initializes. - Login process is TLS terminator for session lifetime.
yarilo-imap-loginholds the TLS conn and proxies plain bytes toyarilo-imapvia Unix socket pair. Session processes have zero TLS knowledge. - fd-passing via SCM_RIGHTS. Master binds ports, passes listening fd to login processes. Login passes authenticated conn fd to master after auth success.
pkg/mailboxinterfaces are the contract between all storage implementations. Never importinternal/storage/*from outside the session process packages.- Per-user storage handle (Dovecot
mail_storagepattern). Storage backends exposeOpenUser(*UserInfo) UserMailbox. Sessions callOpenUseronce after auth. Handle methods take NO user/path parameter -UserInfois captured at Open time. - Director owns the ring. Nothing else modifies backend assignment.
- Internal protocols (director, auth, warden, ipc) are TAB-delimited, LF-terminated, with version handshake. See INTERNALS.md in the private yarilomail/docs-internal repo for the exact wire format.
- Each process writes only to its own resources. No cross-process writes to shared state.
- Cross-process write coordination always goes through
yarilo-locks. Singlepkg/locksAPI, single TAB-delimited wire protocol. In every k8s Helm release (standalone or backend) the binary runs inremotemode - its own Deployment, mTLS TCP:9104, Redis-backed state.embeddedmode (Unix socket + in-memory) exists for unit tests and CLI dev runs only; it cannot scale across pods so it is never the k8s default. Never usefcntl/flock, never bypass with rawsync.Mutexacross process boundaries.sync.Mutexstays only as in-process fast-path before anyyarilo-lockscall. - Dovecot feature parity is the North Star. Every feature that exists in Dovecot is in-scope for yarilo over time - namespaces (personal/shared/public), vdirs (virtual mailboxes), Sieve, ManageSieve, fts (full-text search), replication (dsync), quota, ACL, imap-hibernate, password schemes, etc. "Not in this PR" and "next phase" are acceptable; "out of scope forever" is not. Audit gaps against Dovecot when designing each phase.
- Every Dovecot-modelled feature ships with
yarilo.yaml+ Helmvalues.yamlknobs. Tunables are exposed inpkg/config/config.gounder the relevant section (protocol.*/auth.*/storage.*/general.*) AND inhelm/values.yaml. Defaults may match Dovecot's defaults but operators must always be able to override. No hard-coded behaviour that is not also a config knob. Match Dovecot's option names where reasonable sodovecot.conf→yarilo.yamlmigration stays mechanical.
One source of defaults: config.Defaults(), filled before the file is
read; helm/values.yaml carries the same numbers and app/guard checks they
are equal (#2167).
- A key the file leaves out keeps its default; a hand-written
yarilo.yamlfrom an earlier release must keep starting. An explicit value is kept. - The chart renders every key raw: no
| default N, nohasKey … else N, nodig … N. An explicit0,falseor""must reach the config. - No consumer turns a value into another (
if x <= 0 { x = 30 }). - An explicit
0turns the setting off (offKeysinpkg/config/zero_off.go); where off makes no sense it is refused at load (requiredKeys). A negative value is refused. A meaning a release documented does not change in a patch. - A list entry's numeric field has no slot in
Defaults(): presence in the file decides (listEntryDefaults).
- Comments: see "Code comments" below.
- No half-finished implementations. Stub with
return errors.New("not yet implemented")- never leave a function body empty or silently broken. - Error wrapping: always
fmt.Errorf("package/op: %w", err)- never bareerr. - Table-driven tests: all test cases in
[]struct{ ... }slices. No ad-hoc repeated calls. - No
init()functions. Wire everything explicitly inbackend.New()ormain(). - Context propagation: every long-running operation accepts
context.Contextas first arg. t.TempDir()for all test filesystem work - never hardcoded/tmppaths.
A comment exists only when the WHY is non-obvious: a hidden constraint, a subtle invariant, a compatibility quirk, or a "do not X" backed by a test row that fails when X is done. Everything else - the code and the documentation repos already say it.
- Never explain WHAT the code does. The code says that.
- Size: 1–2 lines is the norm. A block of 3 or more lines is a red flag -
move the explanation to the documentation repo (public behaviour) or to
INTERNALS.mdin docs-internal (wire formats, on-disk layouts) and keep a one-line pointer at most. - Facts, not stories: no history ("previously…", "used to be…"), no
meta-narrative about how the code got here. History belongs in issues -
reference it as
(#NNNN)if it matters. - Exported identifiers keep a godoc comment; its first sentence is the contract. That is API documentation, not commentary - keep it, keep it short.
- Untouchable:
//go:*directives,//nolint, cgo preambles, license headers, generated files. - English only.
- Ceiling: lines sitting in blocks of 3+ stay ≤8% of Go lines per package,
measured by
hack/commentcensusand enforced by the CI gate (#1620). Blocks, not total comment lines: one-line godoc is kept, so counting it would fail a small package holding no narrative at all.
log/slogwith JSON handler everywhere. Neverlog.Printf,log.Fatal,fmt.Println.LOG_LEVEL=debugenablesslog.LevelDebugat startup - no code changes needed.- Every significant operation logs at
slog.Infowith structured key-value pairs. - Errors always logged with
slog.Error("context msg", "err", err).
- Every new package gets
*_test.goin the same commit that introduces the package. - Cover: happy path, edge cases (empty input, not-found, concurrent access where relevant).
- Use
t.TempDir()for filesystem tests - never leave temp files behind. - Forbidden in unit tests: network connections, real IMAP/SMTP ports, sleep.
- A trap that does not go red on the mutation is not proven - it is unexplained. Until it is named why the mutation did nothing, "the path is not reachable in a test" is a guess, not a fact. Twice that guess hid a blind test (#1652). See the "blind trap" entry in docs-internal/benchmark-method.md.
- Every new protocol port gets a smoke check added to
app/smoketest/main.go. - Smoke test connects to the live deployment: IMAP TLS greeting,
/healthz,/readyz. - Run via
smoke.ymlworkflow (manual dispatch or called from deploy pipeline). - Exit 0 = all checks passed. Exit 1 = failure with per-check output.
- Will live in
test/integration/- not yet implemented.
Order: lint → test → build & push → release.
lint: golangci-lint + hadolint +helm linttest:go test ./...with go-junit-report → dorny/test-reporterbuild: GHCR push onmainonly; tag = git SHA +latest+ semver if new releaserelease: triggered automatically whenhelm/Chart.yamlappVersionis a new tag - git-cliff generates release notes,gh release createpublishes.
Never push directly to main. Feature branch → PR → user merges.
- Chart lives in
helm/. One chart, one app. appVersioninChart.yamlis the single source of truth for the release version. Bump it → CI creates the GitHub Release and GHCR tag automatically.strategy: Recreate- ensures old pod releases the PVC before new pod mounts it.replicaCount: 1for single-node mode. Multi-node uses yarilo-director (Phase 5).
- Wire formats for all internal protocols are documented in
INTERNALS.md, which lives in the private yarilomail/docs-internal repo (it is not part of this repo, anddocs_internal/stays in .gitignore so a local copy cannot leak into a public one). Always consult it before implementing any binary format or internal socket. - Magic bytes, version numbers, field offsets - must match exactly. See §7 (FileIndex), §8 (Maildir), §2 (director protocol), §3 (auth protocol).
Every issue carries exactly one type label at creation - this is what keeps the bug statistics honest (defects are not drowned in backlog):
bug- behaviour diverges from what is documented or intended. A defect found in review is alwaysbug.enhancement- new feature or parity work someone asked for.phase- a large roadmap stage (JMAP-N, AUTH-N, REPL, OBOX): backlog, not a request. Never alsoenhancement.decision- needs a decision before code; the issue records the choice.documentation,question- as usual.
Severity, only on bug and only when it applies: data-loss (user data lost
or corrupted), security (disclosure, oracle, rights bypass).
Subsystem labels are optional but encouraged: imap, pop3, lmtp,
submission, sieve, jmap, fts, acl, auth, director, storage,
backend-api, config, testing.
Label management (create/edit) requires the 0kaba0hub account
(gh auth switch); 0kaba0 can label issues it opens via --label.
Before bumping appVersion in helm/Chart.yaml:
- All unit tests pass (
go test ./...) helm lint helm/yarilopasses- Smoke test passes against staging
- README updated if new feature/env var/value was added