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.
## 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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.
Mailhad 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:
1. Mutation RPCs address an occupancy, not a message
MoveMessage,DeleteMessage,FlagMessageeach take an optionalPlacementRef {account_id, folder_id, uidvalidity, uid}. Identity is content-addressed and folder-independent, so one message can sit in several mailboxes; aiming aMOVEorDELETEat 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_idand report "queued", never "done": only the executor can say whether the server applied, conflicted, or proved nothing.2.
Conflictis a typed error and durable stateAppended
ERROR_REASON_CONFLICT = 13(append-only; nothing renumbered), surfaced through the establishedErrorInfostatus-details mechanism and mapped toCode::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 staysUnknownand retryable.Per §5, conflicts are durable and queryable: a
mutation_conflictstable (inmigrate()'sCREATE TABLE IF NOT EXISTSblock — created, never migrated), written by the executor when a drain pass hitsDisposition::Conflict, withListConflicts/ResolveConflictover 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.
Messagecarries its placement setNew
repeated Placement placements = 12(account, folder, uidvalidity, uid, read).folder_idkeeps 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_enabledon the wireAccountConfig.filters_enabled = 18, readable on the account messages and settable throughUpdateAccount. Still per-account and still default off — filters take irreversible remote actions — but no longer reachable only by hand-editing theaccountsrow, 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.protochanges 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 generatednuncio_v1_descriptor.binover the committeddescriptor.bin); the binary was never hand-edited.Gates
cargo fmt --all -- --check,cargo check-all, andcargo test-allare green.RUST_TEST_THREADS=2 cargo test-allwas run three additional times with zeroFAILEDlines 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.rsplus an executor unit test; all protocols mocked, no live network.Closes #395