Give reminder cancellation its own store and IPC path (V-719)

CancelReminder replaces the cancelled half of MarkReminder, which stays
delivery-only. Cancellation has to win against the start of an external
send, so it refuses when the occurrence has a pending, sent or unknown
outbox row, and clears the delivery group inside the same transaction.
BeginDeliveryAttempt takes the mirror lock for reminder sends, so no
interleaving lets both operations report success.

Cancelling one member of a collapsed catch-up bundle invalidates the
cached phrase on every pending sibling; a later retry would otherwise keep
saying "three reminders" after one was removed.

Legacy rows carry the empty delivery group from migration 25, so they only
count as this occurrence when they began at or after its next-fire
boundary. Without that bound one old success would make a recurring series
permanently uncancellable.

ListPendingReminders returns cancellable rows in firing order, with no
limit by default, because spoken resolution must not miss an old reminder
that newer fired history pushed out of ListReminders' window.

Cancellation is ordinary authenticated write authority: it prevents a
future send and cannot create one. cmd/e2eprobe drives both from outside.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 17:19:13 +04:00
parent 5b0b29dfad
commit 0b057df2a3
17 changed files with 846 additions and 38 deletions
+3 -1
View File
@@ -68,7 +68,8 @@ func TestRequirement_Table(t *testing.T) {
reads := []ipc.Method{
ipc.MethodLatestFact, ipc.MethodLatestFactBySource, ipc.MethodSince,
ipc.MethodPresence, ipc.MethodRecentOutcomes,
ipc.MethodCreateReminder, ipc.MethodMarkReminder,
ipc.MethodCreateReminder,
ipc.MethodListPendingReminders,
ipc.MethodRecordNudge, ipc.MethodResolveNudge,
ipc.MethodChat,
}
@@ -424,6 +425,7 @@ func TestRequirement_SwapModel(t *testing.T) {
func TestRequirement_ListMutation(t *testing.T) {
for _, m := range []ipc.Method{
ipc.MethodIngestMail, ipc.MethodSetTaskStatus,
ipc.MethodMarkReminder, ipc.MethodCancelReminder,
} {
if got := Requirement(m); got != AuthWrite {
t.Errorf("%s authority = %v; want AuthWrite", m, got)
+13 -4
View File
@@ -19,9 +19,10 @@ type Authority int8
const (
// AuthRead — read methods (LatestFact, LatestFactBySource, Since, Presence,
// RecentOutcomes) and state mutations a module legitimately makes
// (CreateReminder, MarkReminder, RecordNudge, ResolveNudge). The Enrollment
// already gated caller identity; any enrolled module may use these.
// RecentOutcomes) and additive state mutations a module legitimately makes
// (CreateReminder, RecordNudge, ResolveNudge). The Enrollment already gated
// caller identity; any enrolled module may use these. Terminal list changes
// such as MarkReminder and CancelReminder sit at AuthWrite below.
AuthRead Authority = 0
// AuthWrite — WriteFact. Need enrollment + source-scope match. The
@@ -128,6 +129,14 @@ func Requirement(m ipc.Method) Authority {
// from under him. Same reasoning as WriteFact: a module gets to add to
// its own corner, not to erase his.
return AuthWrite
case ipc.MethodMarkReminder, ipc.MethodCancelReminder:
// Cancelling removes a standing reason for Maven to speak. It is a
// mutation, like resolving a task, but not a step-up act: both voice and
// the web must be able to make Maven quieter at the owner's request.
// MarkReminder now accepts only the delivery-side fired transition, but
// it is terminal too and therefore belongs on the same enrolled-writer
// rung rather than the generic reader rung.
return AuthWrite
case ipc.MethodAssertStepUp:
return AuthRead
case ipc.MethodLatestFact,
@@ -136,7 +145,7 @@ func Requirement(m ipc.Method) Authority {
ipc.MethodPresence,
ipc.MethodRecentOutcomes,
ipc.MethodCreateReminder,
ipc.MethodMarkReminder,
ipc.MethodListPendingReminders,
ipc.MethodRecordNudge,
ipc.MethodResolveNudge,
// Task capture (Vikunja #130). Listed explicitly rather than left to