feat(mail): mutation RPCs, typed conflicts, and the placement set #417

Merged
KofTwentyTwo merged 2 commits from story/mutation-rpcs into dev 2026-08-14 23:57:56 +00:00
KofTwentyTwo commented 2026-08-14 23:49:06 +00:00 (Migrated from github.com)

What a human can now do that they could not before

Before this, the engine could execute verified remote mutations but nothing could ask it to. Mail had no mutation RPCs, so a person could not move, delete, or flag a message at all — the placement work had an engine and nothing else.

Now, against a running daemon:

nuncio mail move   --id <msg> --to Archive [--placement acct/INBOX/1/101]
nuncio mail delete --id <msg> [--placement ...]
nuncio mail flag   --id <msg> [--placement ...]
nuncio mail unflag --id <msg> [--placement ...]
nuncio mail conflicts [--all]
nuncio mail resolve --id <conflict> --note "kept the remote change"
nuncio account filters --id <acct> --enable

1. Mutation RPCs address an occupancy, not a message

MoveMessage, DeleteMessage, FlagMessage each take an optional PlacementRef {account_id, folder_id, uidvalidity, uid}. Identity is content-addressed and folder-independent, so one message can sit in several mailboxes; aiming a MOVE or DELETE at the wrong occupancy is destructive and irreversible. The outbox already refused an ambiguously-addressed mutation with a permanent disposition — the queued row now records the occupancy the intent was formed against, which turns that refusal into something a caller can avoid rather than a dead end. A named occupancy is honoured only after confirming the message still holds it; naming one it has left is refused, never silently re-aimed. Omitting it resolves only when there is exactly one placement, and otherwise fails with a typed error listing the candidates.

Everything goes through the existing outbox path — no second mutation route. RPCs return the outbox mutation_id and report "queued", never "done": only the executor can say whether the server applied, conflicted, or proved nothing.

2. Conflict is a typed error and durable state

Appended ERROR_REASON_CONFLICT = 13 (append-only; nothing renumbered), surfaced through the established ErrorInfo status-details mechanism and mapped to Code::Aborted, so a client predating it takes the error path instead of misreading success. Per ADR 0002 §4 a conflict is raised only on the server's positive evidence — an unverifiable outcome stays Unknown and retryable.

Per §5, conflicts are durable and queryable: a mutation_conflicts table (in migrate()'s CREATE TABLE IF NOT EXISTS block — created, never migrated), written by the executor when a drain pass hits Disposition::Conflict, with ListConflicts / ResolveConflict over it. The event stream is a lossy broadcast with no sequence number or resumption, so it can only be an optimisation over this table.

3. Message carries its placement set

New repeated Placement placements = 12 (account, folder, uidvalidity, uid, read). folder_id keeps its exact current meaning — the occupancy the message was reached through — so this is purely additive; its doc comment now points at the new field instead of saying no such field exists.

4. filters_enabled on the wire

AccountConfig.filters_enabled = 18, readable on the account messages and settable through UpdateAccount. Still per-account and still default off — filters take irreversible remote actions — but no longer reachable only by hand-editing the accounts row, which had made filters untestable by hand.

Deferred: DAV conditional write-back

Not in this PR, deliberately. It is calendar/contacts, it does not gate manual mail use, and it would roughly double the size of an already large change. It belongs with the DAV sync surface, not here.

Contract handling

nuncio.proto changes are additive only — new fields, new RPCs, one new enum value. Nothing renumbered, repurposed, or retyped. The byte-exact descriptor golden was regenerated exactly as the test's failure message instructs (rebuild, copy the freshly generated nuncio_v1_descriptor.bin over the committed descriptor.bin); the binary was never hand-edited.

Gates

cargo fmt --all -- --check, cargo check-all, and cargo test-all are green. RUST_TEST_THREADS=2 cargo test-all was run three additional times with zero FAILED lines each, to smoke out the parallelism-sensitive flake class this repo has already paid for.

New offline E2E coverage in crates/nunciod/tests/mutation_rpcs_e2e_test.rs plus an executor unit test; all protocols mocked, no live network.

Closes #395

## What a human can now do that they could not before Before this, the engine could execute verified remote mutations but nothing could ask it to. `Mail` had no mutation RPCs, so a person could not move, delete, or flag a message at all — the placement work had an engine and nothing else. Now, against a running daemon: ``` nuncio mail move --id <msg> --to Archive [--placement acct/INBOX/1/101] nuncio mail delete --id <msg> [--placement ...] nuncio mail flag --id <msg> [--placement ...] nuncio mail unflag --id <msg> [--placement ...] nuncio mail conflicts [--all] nuncio mail resolve --id <conflict> --note "kept the remote change" nuncio account filters --id <acct> --enable ``` ### 1. Mutation RPCs address an occupancy, not a message `MoveMessage`, `DeleteMessage`, `FlagMessage` each take an optional `PlacementRef {account_id, folder_id, uidvalidity, uid}`. Identity is content-addressed and folder-independent, so one message can sit in several mailboxes; aiming a `MOVE` or `DELETE` at the wrong occupancy is destructive and irreversible. The outbox already refused an ambiguously-addressed mutation with a permanent disposition — the queued row now records the occupancy the intent was formed against, which turns that refusal into something a caller can avoid rather than a dead end. A named occupancy is honoured only after confirming the message still holds it; naming one it has left is refused, never silently re-aimed. Omitting it resolves only when there is exactly one placement, and otherwise fails with a typed error listing the candidates. Everything goes through the existing outbox path — no second mutation route. RPCs return the outbox `mutation_id` and report "queued", never "done": only the executor can say whether the server applied, conflicted, or proved nothing. ### 2. `Conflict` is a typed error and durable state Appended `ERROR_REASON_CONFLICT = 13` (append-only; nothing renumbered), surfaced through the established `ErrorInfo` status-details mechanism and mapped to `Code::Aborted`, so a client predating it takes the error path instead of misreading success. Per ADR 0002 §4 a conflict is raised only on the server's positive evidence — an unverifiable outcome stays `Unknown` and retryable. Per §5, conflicts are durable and queryable: a `mutation_conflicts` table (in `migrate()`'s `CREATE TABLE IF NOT EXISTS` block — created, never migrated), written by the executor when a drain pass hits `Disposition::Conflict`, with `ListConflicts` / `ResolveConflict` over it. The event stream is a lossy broadcast with no sequence number or resumption, so it can only be an optimisation over this table. ### 3. `Message` carries its placement set New `repeated Placement placements = 12` (account, folder, uidvalidity, uid, read). `folder_id` keeps its exact current meaning — the occupancy the message was reached through — so this is purely additive; its doc comment now points at the new field instead of saying no such field exists. ### 4. `filters_enabled` on the wire `AccountConfig.filters_enabled = 18`, readable on the account messages and settable through `UpdateAccount`. Still per-account and still default off — filters take irreversible remote actions — but no longer reachable only by hand-editing the `accounts` row, which had made filters untestable by hand. ## Deferred: DAV conditional write-back Not in this PR, deliberately. It is calendar/contacts, it does not gate manual **mail** use, and it would roughly double the size of an already large change. It belongs with the DAV sync surface, not here. ## Contract handling `nuncio.proto` changes are additive only — new fields, new RPCs, one new enum value. Nothing renumbered, repurposed, or retyped. The byte-exact descriptor golden was regenerated exactly as the test's failure message instructs (rebuild, copy the freshly generated `nuncio_v1_descriptor.bin` over the committed `descriptor.bin`); the binary was never hand-edited. ## Gates `cargo fmt --all -- --check`, `cargo check-all`, and `cargo test-all` are green. `RUST_TEST_THREADS=2 cargo test-all` was run three additional times with zero `FAILED` lines each, to smoke out the parallelism-sensitive flake class this repo has already paid for. New offline E2E coverage in `crates/nunciod/tests/mutation_rpcs_e2e_test.rs` plus an executor unit test; all protocols mocked, no live network. Closes #395
Sign in to join this conversation.