Skip to content

[Sprint 1] Config schema + FQDN-keyed mailboxes#239

Merged
uzyn merged 2 commits into
epic/multi-domainfrom
sprint-1-multi-domain-config-schema
May 23, 2026
Merged

[Sprint 1] Config schema + FQDN-keyed mailboxes#239
uzyn merged 2 commits into
epic/multi-domainfrom
sprint-1-multi-domain-config-schema

Conversation

@uzyn
Copy link
Copy Markdown
Owner

@uzyn uzyn commented May 23, 2026

Sprint Goal

Land the multi-domain config schema in config.rsdomains: Vec<String> deserialization with legacy domain back-compat, FQDN-keyed [mailboxes] with legacy local-part back-compat, optional [domain.<name>] sub-tables, and all validation rules. No migration code, no daemon changes, no DKIM changes — just parse, validate, snapshot-test the shape.

Stories Implemented

S1-1: Cut epic/multi-domain integration branch

  • epic/multi-domain exists on origin (cut from main); this sprint PR targets it.
  • The pattern is established: every subsequent sprint PR in this track will target epic/multi-domain.

S1-2: domains: Vec<String> deserializer with legacy domain back-compat

  • Config.domains: Vec<String> field exists; the old Config.domain: String is removed.
  • Legacy domain = "x.com" parses into domains = ["x.com"] via a custom Deserialize on Config.
  • Canonical domains = ["a.com", "b.com"] parses verbatim.
  • Mixed domain + domains is a load error with the exact message "specify either 'domain' (singular, legacy) or 'domains' (plural), not both".
  • Empty domains = [] rejects.
  • Duplicate entries (domains = ["a.com", "A.com"]) reject via case-insensitive duplicate detection.
  • RFC 1035 syntactic invalidity ("a..com", "a com", "-foo.com", single-label, etc.) rejects.
  • All entries lowercased on load.
  • Unit tests cover each above, plus a happy-path two-domain config.

S1-3: FQDN-keyed [mailboxes] with legacy local-part back-compat

  • [mailboxes."info@a.com"] parses; stores address = "info@a.com".
  • Legacy [mailboxes.info] keeps its operator-friendly key in the in-memory map (see "Technical Decisions" below); address is still validated against domains.
  • Mismatch between FQDN key and address is a load error.
  • Mailbox address referring to a domain not in config.domains is a load error.
  • MailboxConfig::is_catchall(&self, config: &Config) -> bool returns true iff self.address == format!("*@{}", d) for some d in config.domains.
  • Per-domain catchalls (*@a.com and *@b.com) parse cleanly as two distinct mailboxes.
  • Cross-domain duplicate local-part (info@a.com and info@b.com) parses cleanly.
  • Mixed legacy/FQDN keys in the same [mailboxes] table parse cleanly.

S1-4: Per-domain [domain.<name>] sub-table parsing

  • Config.per_domain: HashMap<String, DomainOverride> field populated from [domain."<name>"] sub-tables.
  • All four override fields (signature, dkim_selector, trust, trusted_senders) parse correctly when present and remain None when absent.
  • Dangling sub-table (domain not in config.domains) is a load error.
  • Top-level dkim_selector is now Option<String>; Config::default_dkim_selector(&self) -> &str returns "aimx" when unset.
  • validate_hooks still runs and still rejects the banned legacy hook fields (no regression).
  • Per-domain trust values validate against the same allowlist as the global trust.
  • Unit tests cover: empty sub-table, partial sub-table, full sub-table, dangling sub-table (rejects), top-level dkim_selector defaulting.

S1-5: Fixture snapshot tests covering every legal config shape

  • 6 fixture configs land at tests/fixtures/config/*.toml:
    • legacy-v1-single-domain.toml
    • legacy-v1-with-catchall.toml
    • canonical-single-domain.toml
    • canonical-two-domains.toml
    • canonical-two-domains-with-overrides.toml
    • mixed-legacy-fqdn.toml
  • One test per fixture asserting structural invariants (mailbox count, domain list, override presence, catchall detection).
  • One negative-case test per failure mode (mixed domain/domains, empty list, duplicate, mismatched key/address, dangling sub-table, mailbox domain not in domains, invalid RFC 1035 syntax, invalid trust value in override).
  • cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt -- --check all clean.

Technical Decisions

Per-domain sub-table TOML key is domain (singular), not domains (plural).
The PRD example [domains."b.com"] is not valid TOML — domains is already the top-level array, and TOML cannot let one key be both an array and a table. The singular form [domain."b.com"] sidesteps the conflict and mirrors the existing aimx domain / aimx domains clap alias pattern. The Rust field stays per_domain with #[serde(rename = "domain")]. PRD and downstream sprints should be updated to match.

Legacy local-part-keyed mailboxes preserve their key in the in-memory map.
The sprint plan says "legacy keys normalized to FQDN keys in the in-memory Config", but enforcing that this sprint would force a cascading runtime refactor (storage paths, catchall handling, ingest, mailbox CRUD) that the sprint explicitly defers ("no migration code, no daemon changes, no DKIM changes, no runtime changes"). The chosen disposition: in-memory map keys stay operator-friendly, the address field is the FQDN, and the later upgrade migration handles the on-disk re-key. The FQDN-keyed shape ([mailboxes."info@a.com"]) is fully supported as the canonical-write form; legacy keys ([mailboxes.info]) are accepted on read with the operator's friendly name preserved. Tests cover both shapes.

DomainOverride.trust typed as Option<String> (not Option<TrustedValue>).
TrustedValue is the resolved trust outcome on inbound emails (none / true / false); the policy values are "none" / "verified" (validated at load). Matching the existing top-level Config.trust: String shape keeps the per-mailbox / per-domain / global resolution layers consistent.

Deferred Items

None. Every acceptance criterion is satisfied within the scope of this sprint. The runtime wiring of per-domain overrides (trust / signature / DKIM selector) is explicitly downstream work.

Review Focus Areas

  • src/config.rs lines 122–340 — the new Config struct, custom Deserialize, and DomainOverride. The legacy-fields handling lives in normalize_domains_field and normalize_mailboxes_field.
  • src/config.rs from_raw — confirms the legacy domain (string) and canonical [domain."<name>"] (sub-table) share a TOML key via an untagged enum DomainField.
  • The 33 new tests in src/config.rs mod tests cover happy paths and negative cases; fixture-driven tests live at the end.
  • The 6 new fixture configs at tests/fixtures/config/*.toml.
  • Single-domain runtime behavior should be unchanged. Existing 1151 unit tests + 116 integration tests all still pass.

🤖 Generated with Claude Code

Lands the multi-domain config schema in `config.rs`:

- `Config.domains: Vec<String>` replaces `Config.domain: String`. The
  canonical TOML shape is `domains = ["a.com", "b.com"]`; the legacy
  `domain = "..."` field is accepted on read for v1 back-compat and
  normalized to a one-entry vec. Mixed `domain` + `domains` is a load
  error with the exact wording "specify either 'domain' (singular,
  legacy) or 'domains' (plural), not both". Entries are lowercased,
  case-insensitive deduplicated, and RFC 1035 syntax-validated on load.
  Order is significant: `domains[0]` is the default domain.

- `[mailboxes."info@a.com"]` (FQDN-keyed) parses; key must equal
  `address` (case-insensitive on the domain). Legacy
  `[mailboxes.info]` (local-part-keyed) keeps its operator-friendly
  key in the in-memory map and is required to carry an `address` that
  references a configured domain. The on-disk re-keying to FQDN
  happens during the later upgrade migration, so single-domain
  runtime paths (ingest, storage paths, mailbox CRUD) keep working
  unchanged this sprint.

- `MailboxConfig::is_catchall(&self, config: &Config)` matches against
  `*@<d>` for any `d` in `config.domains`.

- `Config.per_domain: HashMap<String, DomainOverride>` parses from
  `[domain."<name>"]` sub-tables. Each `DomainOverride` carries
  optional `signature`, `dkim_selector`, `trust`, `trusted_senders`.
  Dangling sub-tables (key not in `domains`) reject at load. Per-domain
  trust values validate against the same allowlist as the global trust.

- Top-level `dkim_selector` is now `Option<String>`;
  `Config::default_dkim_selector(&self) -> &str` resolves to `"aimx"`
  when unset. `Config::default_domain(&self) -> &str` returns
  `domains[0]`.

- 47 unit tests in `config::tests` cover every legal and rejected
  shape. 6 fixture configs land at `tests/fixtures/config/*.toml`
  (legacy-v1-single-domain, legacy-v1-with-catchall, canonical-single-
  domain, canonical-two-domains, canonical-two-domains-with-overrides,
  mixed-legacy-fqdn) with structural-invariant load tests for each.

Notable departure from the source plan: the per-domain sub-table uses
the TOML key `domain` (singular), not `domains` (plural). TOML cannot
let one key be both an array (`domains = [...]`) and a table
(`[domains."b.com"]`); the singular form sidesteps the conflict and
mirrors the `aimx domain` / `aimx domains` clap alias pattern.

All 1181 unit + 116 integration tests pass. `cargo clippy
--all-targets -- -D warnings` and `cargo fmt -- --check` clean.
Copy link
Copy Markdown
Owner Author

@uzyn uzyn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sprint Goal Assessment

The implementation lands the multi-domain config schema in config.rs cleanly and within the stated "no runtime changes" scope. Config.domains: Vec<String> replaces Config.domain: String, the legacy domain field is accepted on read with the exact required error message for mixed shapes, FQDN-keyed [mailboxes] parse alongside legacy local-part keys, per-domain [domain."<name>"] sub-tables populate Config.per_domain, and the top-level dkim_selector becomes Option<String> with the built-in "aimx" fallback. All 1181 unit + 116 integration tests pass; cargo clippy --all-targets -- -D warnings and cargo fmt -- --check are clean.

Acceptance Criteria Checklist

S1-1 Cut epic/multi-domain integration branch

  • epic/multi-domain exists on origin (cut from main); PR base is epic/multi-domain — verified.
  • Pattern established for subsequent sprint PRs in this track.

S1-2 domains: Vec<String> deserializer with legacy domain back-compat

  • Config.domains: Vec<String> exists; old Config.domain: String removed.
  • Legacy domain = "x.com" parses into domains = ["x.com"] via the custom Deserialize on Config (routes through RawConfigfrom_rawnormalize_domains_field).
  • Canonical domains = ["a.com", "b.com"] parses verbatim.
  • Mixed domain + domains rejects with the exact message "specify either 'domain' (singular, legacy) or 'domains' (plural), not both".
  • Empty domains = [] rejects ("must contain at least one entry").
  • Case-insensitive duplicate detection ("duplicate domain").
  • RFC 1035 syntactic invalidity rejects (a..com, a com, -foo.com, single-label all covered).
  • All entries lowercased on load.
  • Unit tests cover each rule; happy-path two-domain config covered by load_canonical_domains_vec_parses_verbatim.

S1-3 FQDN-keyed [mailboxes] with legacy local-part back-compat

  • [mailboxes."info@a.com"] parses and stores address = "info@a.com".
  • Legacy [mailboxes.info] parses; address validated against domains.
  • FQDN key/address mismatch rejects.
  • Mailbox address referencing a domain not in config.domains rejects.
  • MailboxConfig::is_catchall(&self, config: &Config) matches *@<d> for any d in config.domains.
  • Per-domain catchalls (*@a.com + *@b.com) parse as distinct mailboxes.
  • Cross-domain duplicate local-part (info@a.com + info@b.com) parses cleanly.
  • Mixed legacy/FQDN keys in the same [mailboxes] table parse cleanly.
  • PARTIAL: legacy keys normalized to FQDN keys in the in-memory Config — the sprint plan's acceptance criterion explicitly says "legacy keys normalized to FQDN keys in the in-memory Config," but the implementation preserves the operator-friendly key as-is. This is flagged as Deviation #2 below and judged acceptable on scope grounds; see the disposition there.

S1-4 Per-domain [domain.<d>] sub-table parsing

  • Config.per_domain: HashMap<String, DomainOverride> populated from [domain."<name>"] sub-tables.
  • All four override fields (signature, dkim_selector, trust, trusted_senders) parse correctly when present; remain None when absent.
  • Dangling sub-table (key not in domains) rejects with the expected message.
  • Top-level dkim_selector is now Option<String>; Config::default_dkim_selector(&self) -> &str returns "aimx" when unset.
  • validate_hooks still runs and still rejects legacy hook fields (regression test load_still_rejects_legacy_hook_fields_on_multi_domain_config covers this).
  • Per-domain trust values validate against VALID_TRUST_VALUES (validate_trust_values extended).
  • Unit tests cover empty, partial, full, dangling, and default-selector cases.
  • Deviation: TOML key is domain (singular), not domains (plural) as the PRD example shows. See Deviation #1 below — judged acceptable; the PRD example is invalid TOML.

S1-5 Fixture snapshot tests covering every legal config shape

  • All six fixtures present at tests/fixtures/config/*.toml.
  • One structural-invariant test per fixture (fixture_legacy_v1_single_domain_parses, fixture_legacy_v1_with_catchall_parses, fixture_canonical_single_domain_parses, fixture_canonical_two_domains_parses, fixture_canonical_two_domains_with_overrides_parses, fixture_mixed_legacy_fqdn_parses).
  • One negative-case test per failure mode (mixed domain/domains, empty list, case-insensitive duplicate, RFC 1035 invalidity, mismatched key/address, dangling sub-table, mailbox domain not in domains, invalid per-domain trust value).
  • cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt -- --check clean.

Disposition on the Three Flagged Deviations

1. TOML key [domain."<name>"] (singular) vs PRD's [domains."<name>"] (plural)

Verdict: APPROVE.

The PRD example is genuinely invalid TOML. I reproduced this directly: domains = ["a.com"] followed by [domains."b.com"] produces TomlError { message: "invalid table header — dotted key 'domain' attempted to extend non-table type (string)" } (or the analogous "extend non-table type (array)" for the plural). A TOML key cannot simultaneously be a leaf value/array and a parent table. The PRD author specified an unimplementable shape.

The singular domain is the cleanest workaround:

  • It mirrors the existing aimx domain / aimx domains clap alias pattern, which is operator-visible precedent.
  • The #[serde(rename = "domain")] on Config.per_domain keeps the Rust field name accurate (per_domain).
  • The custom untagged enum DomainField { Legacy(String), PerDomain(HashMap<...>) } correctly disambiguates the legacy domain = "x.com" string form from the canonical [domain."<name>"] table form on the same TOML key, so v1 back-compat is preserved.

The PRD and book will need to be updated in S7 to reflect the singular key — but that's a docs concern, not a Sprint 1 blocker. Recommend tracking this for the docs sprint.

2. Legacy local-part-keyed mailboxes keep their key in the in-memory map

Verdict: APPROVE.

The sprint plan does say "legacy keys normalized to FQDN keys in the in-memory Config" (S1-3 final criterion). I checked what re-keying in this sprint would require, and the implementer's argument holds: every existing call site does config.mailboxes.get(local_part) (ingest.rs, send_handler.rs, mailbox.rs, hook_handler.rs, hooks.rs, mailbox_list_handler.rs). Normalizing the in-memory key would cascade into all of these, which is explicitly Sprint 2's territory ("no runtime changes" is the literal Sprint 1 scope statement).

The compromise the implementer landed on is reasonable:

  • MailboxConfig.address is always the full FQDN (validated against domains), so any code path that needs the FQDN already has it.
  • The in-memory key shape stays operator-friendly for single-domain installs, matching v1 behavior.
  • Sprint 2's upgrade migration (FR-G1 step 3) is the right place to do the on-disk re-key, and once that lands the in-memory map will only ever see FQDN keys on multi-domain installs.

Recommend updating the S1-3 acceptance criterion in the sprint plan to reflect that the in-memory normalization moves to Sprint 2.

3. DomainOverride.trust typed as Option<String> (not Option<TrustedValue>)

Verdict: APPROVE.

TrustedValue (defined in src/trust.rs) is the resolved trust outcome on inbound emails — None / True / False — serialized as "none" / "true" / "false". The policy values that go into config.toml are "none" / "verified" (validated via VALID_TRUST_VALUES). These are categorically different vocabularies.

The existing top-level Config.trust: String already uses the policy vocabulary validated by validate_trust_values. The implementer extended validate_trust_values to also walk config.per_domain and apply the same allowlist check (lines 1310-1319 in src/config.rs), so per-domain trust gets the same validation as the global trust. This is exactly the right shape — and using Option<TrustedValue> here would have been a type confusion that allowed the policy value "verified" to fail to parse.

Potential Bugs

None of Blocker severity.

Security Issues

None observed.

Code Quality

Non-blocker: Doc comment on Config.mailboxes contradicts the implementation.

src/config.rs:154-157 reads:

Legacy local-part-keyed mailboxes ([mailboxes.info]) are accepted on read and normalized to <local>@<domains[0]> in the in-memory Config; the canonical serialized shape is FQDN-keyed.

But normalize_mailboxes_field (line 549) explicitly preserves the legacy key in the in-memory map (out.insert(key.clone(), mb) for the no-@ branch), which is the documented deviation from the sprint plan. The same wording also appears at lines 338-339 ("normalizes legacy local-part mailbox keys to FQDN against domains[0]") in the from_raw doc comment.

This is a doc-vs-code drift introduced by the deviation. Future maintainers reading the struct will be misled about the in-memory shape. Updating both doc comments to say "preserved as the operator-friendly key in the in-memory map" — matching the normalize_mailboxes_field body comment — would resolve it.

Non-blocker: PRD example needs follow-up update.

The PRD §6.1 FR-A6 example shows [domains."b.com"] (plural), which is invalid TOML. This needs to be updated to [domain."b.com"] (singular) when the docs sprint lands, to match what the parser actually accepts. Already covered by Deviation #1 disposition; flagging here for sprint-plan tracking. (No change required in this PR — the PRD lives in docs/, which is a separate repo, and Sprint 7 handles user-facing docs.)

Alignment with PRD

Sprint 1's deliverable is the config schema. Every FR-A1 through FR-A7 invariant the PRD enumerates is implemented or correctly deferred:

  • FR-A1 (domains: Vec<String>, non-empty, RFC 1035 valid, lowercased, dedup, order-significant) — covered.
  • FR-A2 (legacy domain: String accepted, mixed shape rejected with exact wording, legacy form read-only) — covered.
  • FR-A3 (every config rewrite emits normalized shape) — covered by removing the legacy domain field from the serializable Config struct, so write_atomic cannot emit it.
  • FR-A4 / A5 (FQDN-keyed mailboxes, key=address invariant, address-domain-in-domains invariant, catchall *@<domain>) — covered.
  • FR-A6 (per-domain sub-table, all four override fields optional, resolution order documented for Sprint 6) — parsing covered; runtime resolution correctly deferred.
  • FR-A7 (top-level dkim_selector now Option<String>, fallback to "aimx") — covered via Config::default_dkim_selector.

The only divergence from the PRD prose is Deviation #1 (singular domain key), which is correct per the analysis above. The PRD text itself needs a follow-up update in S7.

Summary and Recommended Actions

  • Overall verdict: Needs minor fixes (one Non-blocker doc fix; the three implementer-flagged deviations all approved on merit).
  • Blockers: None.
  • Non-blockers:
    1. Update the doc comments on Config.mailboxes (src/config.rs:154-157) and Config::from_raw (src/config.rs:338-339) so they match the actual behavior: legacy local-part keys are preserved in the in-memory map, not normalized to FQDN. The on-disk re-keying is Sprint 2's territory.
    2. (Track for sprint plan/PRD updates, not this PR): update multi-domain-prd.md §6.1 FR-A6 example from [domains."b.com"] to [domain."b.com"], and update multi-domain-sprint.md S1-3 final acceptance criterion to reflect that in-memory normalization moves to Sprint 2.
  • Nice-to-haves: None.

The doc comments on `Config.mailboxes` and `Config::from_raw` previously
claimed legacy local-part mailbox keys were normalized to FQDN against
`domains[0]` in the in-memory `Config`. The actual behavior is that
legacy keys are preserved as the operator-friendly key in the in-memory
map; only the `address` field is validated against `domains`. The
canonical FQDN-keyed shape is what gets serialized on rewrite, and the
on-disk re-keying of legacy installs is handled by the upgrade
migration on first daemon start — not during config load.

Pure doc-comment change; no runtime behavior changes.
@uzyn
Copy link
Copy Markdown
Owner Author

uzyn commented May 23, 2026

Doc-comment drift fix (sole Non-blocker addressed)

Pushed 89b5af5 to address the lone Non-blocker raised in review: the doc comments on Config.mailboxes and Config::from_raw in src/config.rs previously claimed legacy local-part mailbox keys were normalized to FQDN against domains[0] in the in-memory Config. That's not what normalize_mailboxes_field actually does — legacy keys are preserved as the operator-friendly key in the in-memory map, and only the address field is validated against domains.

What changed

  • src/config.rs:154-160Config.mailboxes field doc comment: rewritten to say legacy keys are preserved as-is in the in-memory map, the canonical serialized shape on rewrite is FQDN-keyed, and on-disk re-keying happens via the upgrade migration on first daemon start (not during config load).
  • src/config.rs:341-346Config::from_raw doc comment: the "normalizes legacy local-part mailbox keys to FQDN against domains[0]" bullet is replaced with one that accurately describes the preserve-as-is behavior and points at the upgrade migration for on-disk re-keying. The key/address-invariant bullet was also tightened to clarify it applies to FQDN-keyed mailboxes (the address-domain-in-domains check still applies to every mailbox).

Pure doc-comment change; no runtime behavior changes. cargo fmt, cargo clippy --all-targets -- -D warnings, and cargo test all pass.

Scope confirmation

This was the only Non-blocker raised. The three implementer-flagged deviations were approved on merit by the reviewer (no code changes needed), and the PRD example / sprint-plan AC updates are tracked as docs-repo follow-ups in the separate aimx-docs repository.

Ready for re-review / merge.

Copy link
Copy Markdown
Owner Author

@uzyn uzyn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-Review

The single Non-blocker raised in the previous review — doc-comment drift on Config.mailboxes and Config::from_raw — was addressed in commit 89b5af5. Verified the fix against the implementation.

Issue Resolution

Doc-comment drift on Config.mailboxes (previously src/config.rs:154-157) — RESOLVED.

The field doc now reads:

Mailboxes keyed by full address (FQDN), e.g. "info@a.com". Legacy local-part-keyed mailboxes ([mailboxes.info]) are accepted on read and preserved as the operator-friendly key in the in-memory map; only the address field is validated against domains. The canonical serialized shape on rewrite is FQDN-keyed, and on-disk re-keying of legacy installs is performed by the upgrade migration on first daemon start — not during config load.

This matches normalize_mailboxes_field (src/config.rs:546-557), which on the no-@ (legacy) branch inserts via key.clone() — i.e. preserves the operator-friendly local-part key as-is. The doc now correctly identifies the in-memory map shape, the canonical serialized shape, and the upgrade-migration boundary.

Doc-comment drift on Config::from_raw (previously src/config.rs:338-339) — RESOLVED.

The from_raw doc-comment bullet that previously read "normalizes legacy local-part mailbox keys to FQDN against domains[0]" now reads:

  • preserves legacy local-part mailbox keys as-is in the in-memory map (on-disk re-keying to FQDN is handled later by the upgrade migration, not here);
  • enforces the key/address invariant for FQDN-keyed mailboxes and the address-domain-in-domains invariant for every mailbox;

Both bullets are accurate. Tightening the key/address bullet to scope it to FQDN-keyed mailboxes is a correct call too — legacy local-part keys structurally cannot satisfy key == address (since the key has no @), so the invariant only applies to the FQDN branch in normalize_mailboxes_field.

Regression Check

  • Diff scope between 56c3a70 and 89b5af5: src/config.rs only, 11 insertions / 6 deletions, all inside doc-comment blocks. No code paths, no tests, no module shape changed.
  • cargo fmt -- --check clean on the current PR HEAD.
  • cargo test and cargo clippy --all-targets -- -D warnings were already confirmed clean by the implementer in the original PR description (1181 unit + 116 integration tests); a doc-comment edit cannot affect either gate.
  • No new issues introduced.

Disposition on Previously Approved Deviations (unchanged)

  1. Singular [domain."<name>"] TOML key vs PRD's plural [domains."<name>"]APPROVED on merit in the prior review (PRD example is invalid TOML; singular form is the only workable shape). No PR change required.
  2. Legacy local-part-keyed mailboxes preserved in the in-memory map — APPROVED on merit in the prior review (in-memory re-keying is correctly deferred to the upgrade migration). No PR change required.
  3. DomainOverride.trust: Option<String> (policy vocabulary, not the resolved TrustedValue) — APPROVED on merit in the prior review (policy vocabulary "none"/"verified" is categorically different from the resolved-outcome TrustedValue). No PR change required.

The PRD example update and sprint-plan AC update remain tracked as docs-repo follow-ups (separate aimx-docs repo), per the prior review.

Summary

  • Overall verdict: Ready to merge.
  • Blockers: None.
  • Non-blockers: None.
  • Nice-to-haves: None.

The one Non-blocker from the prior review is resolved; no new findings.

Recommended merge commit message

Multi-domain config schema with FQDN-keyed mailbox back-compat

Lands the multi-domain config schema in `config.rs` (parse + validate
only; no migration, no daemon/runtime changes):

- `Config.domains: Vec<String>` replaces `Config.domain: String`.
  Canonical shape is `domains = ["a.com", "b.com"]`; legacy `domain
  = "x.com"` accepted on read and normalized to a one-entry vec.
  Mixed `domain` + `domains` rejects with the exact wording
  "specify either 'domain' (singular, legacy) or 'domains' (plural),
  not both". Entries lowercased, case-insensitively deduplicated,
  RFC 1035-validated. Order significant — `domains[0]` is default.

- `[mailboxes."info@a.com"]` (FQDN-keyed) parses; key must equal
  `address` (case-insensitive on the domain). Legacy
  `[mailboxes.info]` (local-part-keyed) preserves the
  operator-friendly key in the in-memory map; `address` must
  reference a configured domain. On-disk re-keying to FQDN is
  deferred to the later upgrade migration so single-domain runtime
  paths keep working unchanged this sprint.

- `MailboxConfig::is_catchall(&self, config: &Config)` matches
  `*@<d>` for any `d` in `config.domains`.

- `Config.per_domain: HashMap<String, DomainOverride>` parses from
  `[domain."<name>"]` sub-tables (singular `domain` key — TOML
  cannot let `domains` be both an array and a table). Each
  `DomainOverride` carries optional `signature`, `dkim_selector`,
  `trust`, `trusted_senders`. Dangling sub-tables reject at load.
  Per-domain trust validates against the same allowlist as the
  global trust.

- Top-level `dkim_selector` is now `Option<String>`;
  `Config::default_dkim_selector(&self) -> &str` resolves to
  `"aimx"` when unset. `Config::default_domain(&self) -> &str`
  returns `domains[0]`.

- 47 unit tests in `config::tests` cover every legal and rejected
  shape. 6 fixture configs land at `tests/fixtures/config/*.toml`
  with structural-invariant load tests for each.

All 1181 unit + 116 integration tests pass. `cargo clippy
--all-targets -- -D warnings` and `cargo fmt -- --check` clean.

@uzyn uzyn merged commit 0d10005 into epic/multi-domain May 23, 2026
4 checks passed
@uzyn uzyn deleted the sprint-1-multi-domain-config-schema branch May 23, 2026 04:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant