Story for milestone M4. Read docs/STORY-WORKFLOW.md first — it defines the process, gates, and patterns; this issue defines only the specific work. Two confirmed, distinct correctness bugs live in CompiledFilter::eval_leaf (crates/nuncio-filter/src/engine.rs, lines 81-155), the function that evaluates a single NSQL WHERE-clause leaf condition against an Email:
Line 100-102: FilterField::Folder | FilterField::Account => Self::eval_string_op(&email.folder_id, ...). A rule-level ON ACCOUNT 'x' scope (handled separately and correctly by FilterRule::matches_account, crates/nuncio-filter/src/ast.rs line 281) is fine — but this is the accountcondition field used INSIDE a WHERE clause (e.g. WHERE account = 'acct-a'), and it silently compares against email.folder_id instead of email.account_id. A rule like WHERE account = 'acct-a' AND subject CONTAINS 'x' today matches based on which FOLDER the message is in, not which ACCOUNT it belongs to — a real logic bug a user would never suspect from the rule's own text.
Line 150-153: FilterField::Header(_name) => { /* Header evaluation fallback */ false }. This is not a real evaluation at all — it always returns false regardless of the actual header value, silently making any rule with a header[...] condition impossible to ever match. Root cause: nuncio_core::model::Email (crates/nuncio-core/src/model.rs) has NO field carrying parsed MIME headers at all — there is genuinely nothing to evaluate against yet, so a real per-header match is not achievable without a materially bigger change (adding a headers map to the domain model, populating it in MimeParserAdapter/the IMAP fetch path and the JMAP Email/get properties list, and persisting it in the store). That is out of scope for this correctness-fix story.
Goal
Fix the account condition field to read email.account_id (not email.folder_id). For the header[...] field, since real matching genuinely cannot be done honestly without a domain-model change that is out of scope here, make it fail loudly and honestly at rule-creation/validation time instead of silently evaluating to false at runtime — a user should get a clear "not yet supported" error when they try to create or validate a rule containing a header[...] condition, rather than a rule that persists successfully and then never, ever matches (a much worse failure mode: silent, permanent, unexplained). Do not implement fake header matching (e.g. matching against Subject/From as a stand-in) — that would be exactly the kind of fabricated behaviour the project's anti-theater rule forbids.
Files to touch / patterns to mirror
crates/nuncio-filter/src/engine.rs — CompiledFilter::eval_leaf (lines 81-155): split the FilterField::Folder | FilterField::Account combined match arm (line 100) into two separate arms; Folder keeps &email.folder_id, Account changes to &email.account_id. Update the FilterField::Header(_name) arm's comment (lines 150-153) to explain it is an unreachable defensive fallback (validation now rejects header conditions before a rule can ever reach evaluation), not a real feature.
crates/nuncio-filter/src/validator.rs — NsqlValidator::pass1_field_types (lines 70-108): currently FilterField::Header(_) is grouped with Subject/From/To/Body/Folder/Account in the arm accepting FilterValue::String/List (lines 86-98). Split it out into its own arm that always returns Err(ValidationError::FieldTypeMismatch(...)) with a message stating header-field conditions are not yet supported (message headers are not indexed in the current data model). This is checked by NsqlValidator::validate, which is already called from CreateRule/ValidateRule/PreviewRule in crates/nunciod/src/grpc.rs (FiltersGrpcService::create_rule/validate_rule/preview_rule) — so this single change makes ALL three RPCs (and the CLI commands backed by them) honestly reject a header[...] rule instead of silently accepting one that can never fire.
crates/nuncio-filter/src/engine.rs's #[cfg(test)] mod tests (bottom of file) — existing account-scoping tests (e.g. test_evaluate_scopes_rule_to_its_owning_account, lines 417-432) test the RULE-level ON ACCOUNT scope via FilterRule::matches_account, which is unaffected by this bug and already correct — do not confuse these with the new regression test needed for the account CONDITION field bug.
Implementation steps
In engine.rs, change the eval_leaf match: FilterField::Folder => Self::eval_string_op(&email.folder_id, ...) and FilterField::Account => Self::eval_string_op(&email.account_id, ...) as two distinct arms.
Add a regression test in engine.rs: build a rule via NsqlParser::parse_rule with NSQL like "WHERE account = 'acct-a' ACTION MARK READ", then assert it matches a message with account_id: "acct-a" regardless of folder_id, and does NOT match a message with account_id: "acct-b" even if that message happens to share the same folder_id as an acct-a message (this is the exact scenario the bug produced wrongly before the fix — prove the fix by constructing that specific "same folder, different account" collision).
In validator.rs, split FilterField::Header(_) out of pass1_field_types's combined string-type arm into its own arm returning Err(ValidationError::FieldTypeMismatch("header field conditions are not yet supported: message headers are not indexed".to_string())) (or similar wording).
Add a regression test in validator.rs: NsqlParser::parse_rule(..., "WHERE header['X-Spam-Score'] = '10' ACTION MARK READ") then NsqlValidator::validate(&rule, &ValidationOptions::default()) must return Err(ValidationError::FieldTypeMismatch(_)).
Update engine.rs's eval_leafFilterField::Header(_name) => false arm's doc comment to state it is unreachable in practice (validation rejects the field before a rule persists) and is only a defensive fallback, not a real evaluation — do not delete the arm itself, since eval_leaf must remain exhaustive over FilterField.
Check crates/nunciod/src/grpc.rs's existing create_rule_rejects_invalid_nsql_and_persists_nothing/validate_rule_reports_honest_ok_and_error_verdicts_without_persisting/preview_rule_and_validate_rule_reject_invalid_nsql_syntax tests (grep for them) — none currently construct a header[...] NSQL string, so they should be unaffected, but re-run them to confirm.
Run cargo verify.
Acceptance criteria
WHERE account = '<id>' matches only messages of that account (email.account_id), never messages that merely share a folder_id.
Regression test proves the fix specifically distinguishes "same folder, different account" (the exact bug scenario).
NsqlValidator::validate rejects any rule containing a header[...]WHERE-clause condition with ValidationError::FieldTypeMismatch, so CreateRule/ValidateRule/PreviewRule (and their CLI equivalents) honestly refuse such a rule at creation/validation time instead of silently persisting a rule that can never match.
eval_leaf's FilterField::Header fallback comment is updated to describe it as an unreachable defensive branch, not a real feature — no fake header matching is introduced.
Follow docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in .rs/.proto); mock-only tests. Branch story/m4-filter-correctness off dev; PR to dev with the gate output pasted as evidence; the PO reviews before the issue is closed.
Dependencies / notes
No proto coordination needed; this is a self-contained engine/validator fix, independent of M4-S1/M4-S3/M4-S4 (can land before, after, or in parallel with any of them — it touches only nuncio-filter, not the sync path or the gRPC surface). Real per-header matching (adding a headers map to nuncio_core::model::Email, populating it from MIME parsing and JMAP properties, and persisting it) is real follow-up work worth filing as its own future story if header-based filtering becomes a real requirement — do not attempt it here. Size: M.
## Context
Story for milestone M4. **Read [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md) first** — it defines the process, gates, and patterns; this issue defines only the specific work. Two confirmed, distinct correctness bugs live in `CompiledFilter::eval_leaf` (`crates/nuncio-filter/src/engine.rs`, lines 81-155), the function that evaluates a single NSQL `WHERE`-clause leaf condition against an `Email`:
1. Line 100-102: `FilterField::Folder | FilterField::Account => Self::eval_string_op(&email.folder_id, ...)`. A rule-level `ON ACCOUNT 'x'` scope (handled separately and correctly by `FilterRule::matches_account`, `crates/nuncio-filter/src/ast.rs` line 281) is fine — but this is the `account` *condition field* used INSIDE a `WHERE` clause (e.g. `WHERE account = 'acct-a'`), and it silently compares against `email.folder_id` instead of `email.account_id`. A rule like `WHERE account = 'acct-a' AND subject CONTAINS 'x'` today matches based on which FOLDER the message is in, not which ACCOUNT it belongs to — a real logic bug a user would never suspect from the rule's own text.
2. Line 150-153: `FilterField::Header(_name) => { /* Header evaluation fallback */ false }`. This is not a real evaluation at all — it always returns `false` regardless of the actual header value, silently making any rule with a `header[...]` condition impossible to ever match. Root cause: `nuncio_core::model::Email` (`crates/nuncio-core/src/model.rs`) has NO field carrying parsed MIME headers at all — there is genuinely nothing to evaluate against yet, so a real per-header match is not achievable without a materially bigger change (adding a headers map to the domain model, populating it in `MimeParserAdapter`/the IMAP fetch path and the JMAP `Email/get` properties list, and persisting it in the store). That is out of scope for this correctness-fix story.
## Goal
Fix the `account` condition field to read `email.account_id` (not `email.folder_id`). For the `header[...]` field, since real matching genuinely cannot be done honestly without a domain-model change that is out of scope here, make it fail loudly and honestly at rule-creation/validation time instead of silently evaluating to `false` at runtime — a user should get a clear "not yet supported" error when they try to create or validate a rule containing a `header[...]` condition, rather than a rule that persists successfully and then never, ever matches (a much worse failure mode: silent, permanent, unexplained). Do not implement fake header matching (e.g. matching against `Subject`/`From` as a stand-in) — that would be exactly the kind of fabricated behaviour the project's anti-theater rule forbids.
## Files to touch / patterns to mirror
- `crates/nuncio-filter/src/engine.rs` — `CompiledFilter::eval_leaf` (lines 81-155): split the `FilterField::Folder | FilterField::Account` combined match arm (line 100) into two separate arms; `Folder` keeps `&email.folder_id`, `Account` changes to `&email.account_id`. Update the `FilterField::Header(_name)` arm's comment (lines 150-153) to explain it is an unreachable defensive fallback (validation now rejects header conditions before a rule can ever reach evaluation), not a real feature.
- `crates/nuncio-filter/src/validator.rs` — `NsqlValidator::pass1_field_types` (lines 70-108): currently `FilterField::Header(_)` is grouped with `Subject`/`From`/`To`/`Body`/`Folder`/`Account` in the arm accepting `FilterValue::String`/`List` (lines 86-98). Split it out into its own arm that always returns `Err(ValidationError::FieldTypeMismatch(...))` with a message stating header-field conditions are not yet supported (message headers are not indexed in the current data model). This is checked by `NsqlValidator::validate`, which is already called from `CreateRule`/`ValidateRule`/`PreviewRule` in `crates/nunciod/src/grpc.rs` (`FiltersGrpcService::create_rule`/`validate_rule`/`preview_rule`) — so this single change makes ALL three RPCs (and the CLI commands backed by them) honestly reject a `header[...]` rule instead of silently accepting one that can never fire.
- `crates/nuncio-filter/src/engine.rs`'s `#[cfg(test)] mod tests` (bottom of file) — existing account-scoping tests (e.g. `test_evaluate_scopes_rule_to_its_owning_account`, lines 417-432) test the RULE-level `ON ACCOUNT` scope via `FilterRule::matches_account`, which is unaffected by this bug and already correct — do not confuse these with the new regression test needed for the `account` CONDITION field bug.
## Implementation steps
1. In `engine.rs`, change the `eval_leaf` match: `FilterField::Folder => Self::eval_string_op(&email.folder_id, ...)` and `FilterField::Account => Self::eval_string_op(&email.account_id, ...)` as two distinct arms.
2. Add a regression test in `engine.rs`: build a rule via `NsqlParser::parse_rule` with NSQL like `"WHERE account = 'acct-a' ACTION MARK READ"`, then assert it matches a message with `account_id: "acct-a"` regardless of `folder_id`, and does NOT match a message with `account_id: "acct-b"` even if that message happens to share the same `folder_id` as an `acct-a` message (this is the exact scenario the bug produced wrongly before the fix — prove the fix by constructing that specific "same folder, different account" collision).
3. In `validator.rs`, split `FilterField::Header(_)` out of `pass1_field_types`'s combined string-type arm into its own arm returning `Err(ValidationError::FieldTypeMismatch("header field conditions are not yet supported: message headers are not indexed".to_string()))` (or similar wording).
4. Add a regression test in `validator.rs`: `NsqlParser::parse_rule(..., "WHERE header['X-Spam-Score'] = '10' ACTION MARK READ")` then `NsqlValidator::validate(&rule, &ValidationOptions::default())` must return `Err(ValidationError::FieldTypeMismatch(_))`.
5. Update `engine.rs`'s `eval_leaf` `FilterField::Header(_name) => false` arm's doc comment to state it is unreachable in practice (validation rejects the field before a rule persists) and is only a defensive fallback, not a real evaluation — do not delete the arm itself, since `eval_leaf` must remain exhaustive over `FilterField`.
6. Check `crates/nunciod/src/grpc.rs`'s existing `create_rule_rejects_invalid_nsql_and_persists_nothing`/`validate_rule_reports_honest_ok_and_error_verdicts_without_persisting`/`preview_rule_and_validate_rule_reject_invalid_nsql_syntax` tests (grep for them) — none currently construct a `header[...]` NSQL string, so they should be unaffected, but re-run them to confirm.
7. Run `cargo verify`.
## Acceptance criteria
- [ ] `WHERE account = '<id>'` matches only messages of that account (`email.account_id`), never messages that merely share a `folder_id`.
- [ ] Regression test proves the fix specifically distinguishes "same folder, different account" (the exact bug scenario).
- [ ] `NsqlValidator::validate` rejects any rule containing a `header[...]` `WHERE`-clause condition with `ValidationError::FieldTypeMismatch`, so `CreateRule`/`ValidateRule`/`PreviewRule` (and their CLI equivalents) honestly refuse such a rule at creation/validation time instead of silently persisting a rule that can never match.
- [ ] `eval_leaf`'s `FilterField::Header` fallback comment is updated to describe it as an unreachable defensive branch, not a real feature — no fake header matching is introduced.
- [ ] No proto change.
- [ ] `cargo verify` green (fmt + clippy `-D warnings` all-targets + test-all), 0 ignored.
## Definition of done & process
Follow [`docs/STORY-WORKFLOW.md`](https://github.com/KofTwentyTwo/nuncio/blob/dev/docs/STORY-WORKFLOW.md): no fabricated data; comment convention (intent-only, no story/GH/phase refs in `.rs`/`.proto`); mock-only tests. Branch `story/m4-filter-correctness` off `dev`; PR to `dev` with the gate output pasted as evidence; the PO reviews before the issue is closed.
## Dependencies / notes
No proto coordination needed; this is a self-contained engine/validator fix, independent of `M4-S1`/`M4-S3`/`M4-S4` (can land before, after, or in parallel with any of them — it touches only `nuncio-filter`, not the sync path or the gRPC surface). Real per-header matching (adding a headers map to `nuncio_core::model::Email`, populating it from MIME parsing and JMAP properties, and persisting it) is real follow-up work worth filing as its own future story if header-based filtering becomes a real requirement — do not attempt it here. Size: M.
Delivered via PR #223 (squash-merged to dev). account condition field now reads email.account_id (was folder_id) with a same-folder/different-account regression test; header[...] conditions are now honestly rejected at validation time (FieldTypeMismatch) across CreateRule/ValidateRule/PreviewRule instead of silently never matching. No fake header matching; real per-header support is deferred to a future story (needs a headers map on the domain model).
Delivered via PR #223 (squash-merged to dev). account condition field now reads email.account_id (was folder_id) with a same-folder/different-account regression test; header[...] conditions are now honestly rejected at validation time (FieldTypeMismatch) across CreateRule/ValidateRule/PreviewRule instead of silently never matching. No fake header matching; real per-header support is deferred to a future story (needs a headers map on the domain model).
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.
Context
Story for milestone M4. Read
docs/STORY-WORKFLOW.mdfirst — it defines the process, gates, and patterns; this issue defines only the specific work. Two confirmed, distinct correctness bugs live inCompiledFilter::eval_leaf(crates/nuncio-filter/src/engine.rs, lines 81-155), the function that evaluates a single NSQLWHERE-clause leaf condition against anEmail:FilterField::Folder | FilterField::Account => Self::eval_string_op(&email.folder_id, ...). A rule-levelON ACCOUNT 'x'scope (handled separately and correctly byFilterRule::matches_account,crates/nuncio-filter/src/ast.rsline 281) is fine — but this is theaccountcondition field used INSIDE aWHEREclause (e.g.WHERE account = 'acct-a'), and it silently compares againstemail.folder_idinstead ofemail.account_id. A rule likeWHERE account = 'acct-a' AND subject CONTAINS 'x'today matches based on which FOLDER the message is in, not which ACCOUNT it belongs to — a real logic bug a user would never suspect from the rule's own text.FilterField::Header(_name) => { /* Header evaluation fallback */ false }. This is not a real evaluation at all — it always returnsfalseregardless of the actual header value, silently making any rule with aheader[...]condition impossible to ever match. Root cause:nuncio_core::model::Email(crates/nuncio-core/src/model.rs) has NO field carrying parsed MIME headers at all — there is genuinely nothing to evaluate against yet, so a real per-header match is not achievable without a materially bigger change (adding a headers map to the domain model, populating it inMimeParserAdapter/the IMAP fetch path and the JMAPEmail/getproperties list, and persisting it in the store). That is out of scope for this correctness-fix story.Goal
Fix the
accountcondition field to reademail.account_id(notemail.folder_id). For theheader[...]field, since real matching genuinely cannot be done honestly without a domain-model change that is out of scope here, make it fail loudly and honestly at rule-creation/validation time instead of silently evaluating tofalseat runtime — a user should get a clear "not yet supported" error when they try to create or validate a rule containing aheader[...]condition, rather than a rule that persists successfully and then never, ever matches (a much worse failure mode: silent, permanent, unexplained). Do not implement fake header matching (e.g. matching againstSubject/Fromas a stand-in) — that would be exactly the kind of fabricated behaviour the project's anti-theater rule forbids.Files to touch / patterns to mirror
crates/nuncio-filter/src/engine.rs—CompiledFilter::eval_leaf(lines 81-155): split theFilterField::Folder | FilterField::Accountcombined match arm (line 100) into two separate arms;Folderkeeps&email.folder_id,Accountchanges to&email.account_id. Update theFilterField::Header(_name)arm's comment (lines 150-153) to explain it is an unreachable defensive fallback (validation now rejects header conditions before a rule can ever reach evaluation), not a real feature.crates/nuncio-filter/src/validator.rs—NsqlValidator::pass1_field_types(lines 70-108): currentlyFilterField::Header(_)is grouped withSubject/From/To/Body/Folder/Accountin the arm acceptingFilterValue::String/List(lines 86-98). Split it out into its own arm that always returnsErr(ValidationError::FieldTypeMismatch(...))with a message stating header-field conditions are not yet supported (message headers are not indexed in the current data model). This is checked byNsqlValidator::validate, which is already called fromCreateRule/ValidateRule/PreviewRuleincrates/nunciod/src/grpc.rs(FiltersGrpcService::create_rule/validate_rule/preview_rule) — so this single change makes ALL three RPCs (and the CLI commands backed by them) honestly reject aheader[...]rule instead of silently accepting one that can never fire.crates/nuncio-filter/src/engine.rs's#[cfg(test)] mod tests(bottom of file) — existing account-scoping tests (e.g.test_evaluate_scopes_rule_to_its_owning_account, lines 417-432) test the RULE-levelON ACCOUNTscope viaFilterRule::matches_account, which is unaffected by this bug and already correct — do not confuse these with the new regression test needed for theaccountCONDITION field bug.Implementation steps
engine.rs, change theeval_leafmatch:FilterField::Folder => Self::eval_string_op(&email.folder_id, ...)andFilterField::Account => Self::eval_string_op(&email.account_id, ...)as two distinct arms.engine.rs: build a rule viaNsqlParser::parse_rulewith NSQL like"WHERE account = 'acct-a' ACTION MARK READ", then assert it matches a message withaccount_id: "acct-a"regardless offolder_id, and does NOT match a message withaccount_id: "acct-b"even if that message happens to share the samefolder_idas anacct-amessage (this is the exact scenario the bug produced wrongly before the fix — prove the fix by constructing that specific "same folder, different account" collision).validator.rs, splitFilterField::Header(_)out ofpass1_field_types's combined string-type arm into its own arm returningErr(ValidationError::FieldTypeMismatch("header field conditions are not yet supported: message headers are not indexed".to_string()))(or similar wording).validator.rs:NsqlParser::parse_rule(..., "WHERE header['X-Spam-Score'] = '10' ACTION MARK READ")thenNsqlValidator::validate(&rule, &ValidationOptions::default())must returnErr(ValidationError::FieldTypeMismatch(_)).engine.rs'seval_leafFilterField::Header(_name) => falsearm's doc comment to state it is unreachable in practice (validation rejects the field before a rule persists) and is only a defensive fallback, not a real evaluation — do not delete the arm itself, sinceeval_leafmust remain exhaustive overFilterField.crates/nunciod/src/grpc.rs's existingcreate_rule_rejects_invalid_nsql_and_persists_nothing/validate_rule_reports_honest_ok_and_error_verdicts_without_persisting/preview_rule_and_validate_rule_reject_invalid_nsql_syntaxtests (grep for them) — none currently construct aheader[...]NSQL string, so they should be unaffected, but re-run them to confirm.cargo verify.Acceptance criteria
WHERE account = '<id>'matches only messages of that account (email.account_id), never messages that merely share afolder_id.NsqlValidator::validaterejects any rule containing aheader[...]WHERE-clause condition withValidationError::FieldTypeMismatch, soCreateRule/ValidateRule/PreviewRule(and their CLI equivalents) honestly refuse such a rule at creation/validation time instead of silently persisting a rule that can never match.eval_leaf'sFilterField::Headerfallback comment is updated to describe it as an unreachable defensive branch, not a real feature — no fake header matching is introduced.cargo verifygreen (fmt + clippy-D warningsall-targets + test-all), 0 ignored.Definition of done & process
Follow
docs/STORY-WORKFLOW.md: no fabricated data; comment convention (intent-only, no story/GH/phase refs in.rs/.proto); mock-only tests. Branchstory/m4-filter-correctnessoffdev; PR todevwith the gate output pasted as evidence; the PO reviews before the issue is closed.Dependencies / notes
No proto coordination needed; this is a self-contained engine/validator fix, independent of
M4-S1/M4-S3/M4-S4(can land before, after, or in parallel with any of them — it touches onlynuncio-filter, not the sync path or the gRPC surface). Real per-header matching (adding a headers map tonuncio_core::model::Email, populating it from MIME parsing and JMAP properties, and persisting it) is real follow-up work worth filing as its own future story if header-based filtering becomes a real requirement — do not attempt it here. Size: M.Delivered via PR #223 (squash-merged to dev). account condition field now reads email.account_id (was folder_id) with a same-folder/different-account regression test; header[...] conditions are now honestly rejected at validation time (FieldTypeMismatch) across CreateRule/ValidateRule/PreviewRule instead of silently never matching. No fake header matching; real per-header support is deferred to a future story (needs a headers map on the domain model).