Repository navigation
Mail rules P1a: the rule store (library) - #840
Conversation
…er-gated mode resolution, the ledger's key-closing rules, and the deferred queue
| "outbound groups are not yet supported in P1" | ||
| ); | ||
| } | ||
| decision.write = decision.write || gate_mode(recorded, decision.target, actor)?.write; |
There was a problem hiding this comment.
A raise above a permanent built-in ceiling is recorded rather than refused, and ModeDecision says nothing about it.
This ensure! refuses a raise past the outbound ceiling, but it is keyed on supported() rather than on the ceiling that built_in_holds actually computed — and ActionGroup::Report is supported(). So change_mode("notices", Report, Auto, Some(Actor::Owner), …) succeeds: gate_mode(Propose, Auto, Owner) returns target: Auto, clamped: false, the raise branch passes all three checks, and approvals.jsonl gets an owner-signed to_mode: "auto" row for the spam group.
resolve_mode still clamps effective to Propose (all_action_groups_load_and_outbound_cannot_leave_shadow:153 asserts exactly that), so nothing acts in auto today. Two things follow anyway:
change_modereturnsModeDecision, which has noholdsfield and here reportsclamped: false. P1c'smecha mail rule mode <name> report autohas nothing to print but "set to auto" — but §6.1 requires the CLI to say when it did not do what was asked, and §11 callsspam"the one verb that would reach the provider's filter and has no restore, never runs inauto".- §3 moved the mode out of
rule.toml"precisely so nothing could self-grantauto". Storing an owner-signedautofor the one unrecoverable verb makes the durable record disagree with the invariant, and the only thing standing between it and a liveautospam action is thatbuilt_in_holdskeeps returning the ceiling.
The sibling case is handled by refusing, so the asymmetry looks unintended. The general fix is to compare the target against the hold ceilings resolve_mode already computes (mode_inputs throws resolved.holds away) rather than against supported(), and to surface the ceiling on ModeDecision so the CLI can name it:
let ceiling = resolve_mode(&rule, group, &rows, holds)
.holds
.iter()
.fold(Mode::Auto, |ceiling, hold| ceiling.lower(hold.ceiling));
ensure!(
decision.target.rank() <= ceiling.rank(),
"`{group:?}` is held at {ceiling:?} and cannot be raised further"
);| pub fn is_action_failure(&self) -> bool { | ||
| self.outcome == Outcome::Failed && self.failure_kind == FailureKind::Action | ||
| } |
There was a problem hiding this comment.
FailureKind::Unknown is the default and does not count toward the failure brake — the one place in this file where unknown fails open.
§6.4 names exactly two exclusions from the consecutive-failure brake: Environment and Interrupted. Unknown is neither, but is_action_failure requires a positive Action, so an unclassifiable failure is silently excluded. Two ways that bites:
header()(store.rs:180) hard-codesFailureKind::Unknownandsettled()clones it through, so a P1b caller that settlesOutcome::Failedwithout remembering to setfailure_kindgets a failure that no brake can see. The compiler cannot find that omission — this is the "a closed enum written to an append-only store is a wire format" shape, where the degrade direction matters.failure_kindis read withwire, so a row whose kind this build cannot parse degrades toUnknownand drops out of the streak. Every other unreadable field in this PR degrades toward more restriction (Mode::Unknown→ held at shadow,ActionVerb::Unknown→ closes the key,Outcome::Unknown→ closes); this one degrades toward less braking.
Inverting it keeps the rule the doc states and makes a forgotten field fail closed:
/// §6.4 counts a failure unless it is positively an environment failure
/// or an interruption: an unreadable kind is the rule's failure, not the
/// harness's.
pub fn is_action_failure(&self) -> bool {
self.outcome == Outcome::Failed
&& !matches!(
self.failure_kind,
FailureKind::Environment | FailureKind::Interrupted
)
}If that is deliberate instead — because interrupted() is the only producer today and it does stamp its kind — then the default on header() is the thing to change, so Failed can never be written without a stated kind. Either way it wants a test; nothing currently exercises is_action_failure.
Review — mail rules P1aRead Verified locally (not taken from the description): What I checked against the invariants
TestsThese are the real thing, not scripted-provider theatre: FindingsTwo inline, both medium, both in the direction of a safety mechanism rather than a crash:
Minor — yours to weigh at merge, not requests
|
… reason, an unknown failure kind feeds the brake, and enqueue returns the effective entry id
| !rows.iter().any(|old| old.id == row.id), | ||
| "duplicate ledger row id" | ||
| ); | ||
| if message_closed( | ||
| &rows, | ||
| &MessageKey { | ||
| rule: row.rule.clone(), | ||
| account: row.message.account.clone(), | ||
| provider_message_id: row.message.provider_message_id.clone(), | ||
| action: row.action.clone(), | ||
| }, | ||
| ) || (row.group.once_per_thread() | ||
| && thread_closed( | ||
| &rows, | ||
| &ThreadKey { | ||
| rule: row.rule.clone(), | ||
| account: row.message.account.clone(), | ||
| thread_id: row.message.thread_id.clone(), | ||
| }, | ||
| )) | ||
| { | ||
| return Ok(false); | ||
| } | ||
| } |
There was a problem hiding this comment.
claim_actions is all-or-nothing: one closed sibling key silently blocks the retry of a sibling that Outcome::closes deliberately left open.
Outcome::closes returns false for Failed on Mailbox/PrivateRecord precisely so those attempts can be retried, and interrupted() exists to write that row. But this loop returns Ok(false) for the whole occasion as soon as any presented row's key is closed:
- Rule is
[[action]] do='archive'+[[action]] do='tag'(bothonce_per_thread() == false, so onlymessage_closedapplies). claim_actions([archive_pending, tag_pending])→true; both rows land.tagsettlesDone(closes = true);archivesettlesFailedwith groupMailbox(closes = false).- Next pass re-presents the occasion.
archive's key is correctly open, buttag's surviving settlement row isclosing()→message_closedis true →return Ok(false). - The archive is never retried, and nothing is written to say so.
The caller can't pre-filter its way out of this: the key check is exactly what this function owns under the lock, and the doc comment's own reasoning ("sibling actions share the pre-claim thread check, so one task does not suppress another action in the same occasion") implies the engine presents the whole occasion rather than one action per call — a per-action call would see the previous sibling's thread-closing row.
Returning per-row claim results, or skipping closed rows and claiming the rest, would keep the atomic check while preserving the retryability Outcome::closes is written to express. If the intent is that P1b filters first, the contract is worth stating here, since "already handled" currently means "something in this occasion was handled".
Review: Mail rules P1a — the rule storeI read What I checked and found correct
Observations (minor, not requests)
Provenance of this passEverything I could check against the tree held up, and the regression tests do fail on the behaviour they describe — I re-derived |
…ng no longer blocks a retryable one, while the outbound occasion still blocks together
| fs::create_dir_all(&root)?; | ||
| let root = root.canonicalize()?; | ||
| let rules = root.join("rules"); | ||
| if !rules.try_exists()? { | ||
| fs::create_dir(&rules)?; | ||
| } |
There was a problem hiding this comment.
The store root and rules/ are created with bare fs::create_dir_all / fs::create_dir, so they land at 0o777 & ~umask — 0o755 under the usual umask. Same at line 268 (fs::create_dir(&temp)), which is renamed into place, so every published rules/<name>/ directory is group/world-readable too.
Every other ~/.mecha store goes through crate::create_private_dir instead — outbox.rs:901, trigger.rs:762, learning.rs:1431/1435, persona.rs:1437 — and work.rs's test states the convention outright ("owner-only, like every ~/.mecha leaf"). The files here are correctly 0o600 (write_new, and the .mode(0o600) on each append), so this leaks the directory listing rather than the ledger contents: on a shared machine any local account can enumerate the owner's rule names and watch rules appear and disappear.
Suggest crate::create_private_dir(&root)? / (&rules)? here and in install, with an assertion on the mode of the published rule directory so this cannot regress.
| fs::create_dir_all(&root)?; | |
| let root = root.canonicalize()?; | |
| let rules = root.join("rules"); | |
| if !rules.try_exists()? { | |
| fs::create_dir(&rules)?; | |
| } | |
| pub fn open(root: impl Into<PathBuf>) -> Result<Self> { | |
| let root = root.into(); | |
| crate::create_private_dir(&root)?; | |
| let root = root.canonicalize()?; | |
| let rules = root.join("rules"); | |
| if !rules.try_exists()? { | |
| crate::create_private_dir(&rules)?; | |
| } |
| ensure!( | ||
| entry | ||
| .actions | ||
| .iter() | ||
| .all(|r| r.message.account == entry.account | ||
| && r.message.thread_id == entry.thread_id | ||
| && r.outcome != Outcome::ApprovalLapsed | ||
| && r.settlement.is_none()), | ||
| "deferred actions must belong to the queued thread" | ||
| ); |
There was a problem hiding this comment.
enqueue admits a snapshot whose outcome is Pending, and expire_deferred then cannot leave the key open the way §4.4 requires ("would have, skipped and expired leave it open", design line 848).
The two deferred flows are both permitted by the code but only one is handled:
- Snapshots are not ledger rows (what
DeferredEntry's doc comment onledger.rs:320says, and what the tests use —deferred_rewrites_removes_settled_and_expires_as_expired_not_failurequeues anOutcome::WouldHaverow). Expiry works: theexpired:row hascloses = false, andmessage_closedstays false. - Snapshots are the claimed
Pendingrows.settle_deferred→append_ledger_lockedvalidatesrow.settlementagainst a pending row in the ledger, which only means anything ifclaim_actionsalready appended it, so this flow is anticipated. Butexpire_deferredwrites a new row withid = "expired:…"andsettlement = None.settles()requiresself.settlement.as_deref() == Some(&pending.id), so the original pending row is never suppressed bysurviving(), andOutcome::Pending.closes()istrue(ledger.rs:50) — so the key reads closed forever and the action can never be retried, even though its row saysexpired.
Concretely: claim an archive action (pending row X in the ledger), enqueue that same row as the deferred snapshot, let seven days pass, expire_deferred → ledger holds X (Pending, unsettled) plus expired:<entry>:X; message_closed for that key is true, so the archive is silently dropped rather than retried.
Either enforce the contract — add r.outcome != Outcome::Pending to the ensure! below, so a caller cannot queue an execution row — or have expire_deferred use action.settled(Outcome::Expired, …) when the snapshot is pending. The comment-only contract is the gap; nothing currently fails if P1b picks the second reading.
Review: mail rules P1a (the store)I read The change is sound where it matters most. The invariants I went looking for are all actually enforced rather than asserted about:
Nothing in here touches Two findings, both posted inline
Minor, for the owner to weigh at merge
|
… snapshot can never be an execution row
| rule: rule.name.clone(), | ||
| at: Some(at), | ||
| message, | ||
| action: rule.actions[action].clone(), |
There was a problem hiding this comment.
Minor: the bounds check at line 160-163 is on rule.definition.actions, but the index is then used on the other vector, rule.actions. They're parallel only because load_directory builds them together (store.rs:133), and that zip silently truncates if tables is ever shorter than definition.actions. LoadedRule's fields are all pub, so a P1b caller that constructs one by hand gets a panic instead of an error here.
Not reachable today — both vectors come from the same action array of the same TOML — but a .get() closes it for free:
| action: rule.actions[action].clone(), | |
| action: rule | |
| .actions | |
| .get(action) | |
| .ok_or_else(|| anyhow::anyhow!("no such action identity"))? | |
| .clone(), |
Review: sound. No major or medium finding.I checked this against the invariants in What I verified rather than took on trustSecurity invariants. No tool, no network, no Fail-closed, the shape Both actor gates (§6.1). I traced the matrix by hand and it holds: a non-owner raise errors; a non-owner Digests and keys. The match digest covers exactly §4.2's narrow set and the approval digest everything that decides a send — including Locking and crash windows. Every Tests. The coverage is unusually load-bearing for a store this size, and the ones I expected to be vacuous are not: Minor — for you at merge time, not blocking
One thing worth recording in docs rather than code§3 states the reading as "the effective mode is the latest row for the rule and action group", and §8 as "an unknown mode reads as Merging is yours. 🤖 Generated with Claude Code |
|
Merging after a review pass with no major or medium finding. For P1b, the first caller to construct a |
| group, | ||
| at: Some(now), | ||
| from_mode: resolved.recorded.effective(), | ||
| to_mode: resolved.effective.lower(Mode::Propose), |
There was a problem hiding this comment.
Not a live bug — a question to settle before P4 builds on it. This writes resolved.effective into a durable approvals.jsonl row, and resolve_mode folds every HoldProvider ceiling into effective. So whatever hold happens to be in force at lapse time becomes the recorded mode. lower_modes does the same at store.rs:692 (current there is also the hold-clamped effective).
With P1a's own providers nothing misbehaves: built_in_holds only returns a Shadow ceiling for conditions that are permanent for the build (!group.supported(), is_description()), and P1Holds only returns Propose — which is where a lapse lands anyway. The divergence appears when §5.4's holds land in P4. Two of them — "an unknown own address" and "a provider whose authentication headers cannot be read reliably" — are environment conditions that come and go. A provider outage overlapping an owner's edit would then record shadow rather than propose, and §4.4 makes that durable rather than cosmetic: a lapse to propose does not move the activation cutoff, but a step down to shadow ends the stretch, so the mail that arrived during the outage is orphaned and the owner pays a raise per group. That is the precise cost §6.4 argues against for the consecutive-failure brake ("recovering from someone else's downtime would cost an owner raise per group").
§6.2's wording does say "the lower of that group's current effective mode and propose", so the code is defensible as written — but §3 says the opposite about the same inputs ("A hold leaves the recorded modes intact; it stops evaluation, not the record of what permission the owner set"). The design disagrees with itself here, and this is the line where the disagreement gets resolved. Worth either using resolved.recorded with built_in_holds only (permanent ceilings, not computed ones), or adding a sentence to §6.2 saying computed holds are deliberately baked in and why the activation-stretch cost is accepted.
Review: sound. No major or medium finding.I read Where I pushed hardest, and what heldContainment. This store sits in Fail-closed. The seven paths the second refutation run closed all read as closed to me, each for a reason I could trace: Both actor gates (§6.1). The ledger's wire format. Matches §8's union precisely: settlement lookup first ( Items 1–4 of the review brief are mostly not in scope here: no tool, no network call, no agent-loop or provider contact, and no Tests. These are measuring, not asserting. The digest tests separate what lapses approval from what resets matching history, which is the §6.2/§4.2 distinction that is easy to collapse. Minor, for you to weigh at merge time (not requests)
Merging is yours. Nothing here blocks it. |
|
Merged with the branch behind main on GitHub's view. The gate's intent (test the tree being merged) was met locally: branch + main at fec8d55 was merged in a scratch worktree, and workspace clippy |
Mail rules P1a: the rule store (library)
Phase P1a of
docs/MAIL-RULES-DESIGN.md§11: the mail-rules store, as amecha-corelibrary (mecha-core/src/mail_rules/). There is no matching, no mail I/O and no CLI yet. Those are P1b (engine) and P1c (surface).What it is
rule.tomlloader. Name validation, header matchers,match.describeparsed but not evaluated (P4), andprioritydefaulting to 100. Action groups follow §5's table; outbound groups parse but cannot leaveshadowin P1. Action identity is verb + digest, and a duplicated action is refused. Every named file is proved contained inrules/<name>/, with symlinks out refused.draft_withuses the skill store's own name check, which is whyskill::validate_namebecomespub(crate).approvals.jsonland mode resolution. One row per (rule, group). Authority is replayed against the preceding state: a raise, a write ofoff, and a same-mode row rebinding a digest all need a positiveActor::Owner. A row whose actor, mode or group cannot be read holds the rule rather than being skipped. Lapses are written as per-group rows,min(current, propose), and cover historical groups. An already-off group is excluded. A run'soffis clamped toshadow. Lowering never depends on loading the rule: the owner can always turn a rule off, and an observed load failure steps it down durably. Raises require a successful load and a matching shown digest.ledger.jsonland key closing. Every §4.4/§8 field is written from day one:verdict_input, the match digest's components even when empty,closes, and the settlement→pending link. Closing is the settlement lookup first, then the union of three signals, and it is decided by the presence of a closing row, not the latest one. Rows missing key fields fail closed. An unreadable verb still closes its key by digest and scope.expired, notfailed. Settlement preserves work enqueued concurrently. A complete-but-unterminated final row is terminated before the next append.shadowbefore the directory becomes visible, and refuses a previously used name.How it was checked. Implemented by Codex (
gpt-6-astra, high) from a written brief. Two fresh, read-only Codex runs then tried to refute it:auto, remove-then-restore re-armingauto, reinstall inheriting approvals, ledger rows with missing keys hiding closures, off-valued holds, and an unterminated row poisoning the next append.Tests: 52 store tests; workspace clippy
-D warnings --all-targets --all-featuresclean. Fixtures are made up.