issue 1858: write correct's line in its own transaction, and guard who writes the log (1858.6)

ADR 0517 §5: correct keeps the entry's change, its reading lesson and one corrected line together, or none
of them; a test reads the source to prove only the acts write the action log, and each new verb refuses an
ungranted caller. The README and the callers setting name the twelve verbs.
This commit is contained in:
jochen
2026-10-22 19:56:16 +02:00
parent 0ed94e5466
commit 0e9004664c
7 changed files with 215 additions and 40 deletions
+29 -18
View File
@@ -5,28 +5,33 @@ mesh-mail-notifier (novox/hq ADR 0459). One instance, held once for the mesh, on
on, under its own account (`runs-as`) — it hears mesh-mail's restricted stream `MAIL`, which the operator
account never does. It reads mail; it moves, flags, deletes and sends nothing (ADR 0459 §11).
## What it does, and what is still to land
## What it does
- **Claims `mail-reading`** (held once for the mesh), declared in mesh-mail's own manifest beside its three
mail seats, serving six verbs: `entries`, `groups`, `correct`, `lessons`, `undo`, `status` (ADR 0459 §2).
Each answers only a caller the trusted setting `callers` names (default `["mesh-mail-web"]`); any other
caller is refused, naming why.
- **Hears mesh-mail's added events** and judges a batch of up to 6 at a time (ADR 0516 §3) against a question put to
`claude_code_judge`, keeping an entry per mail — never its body — in its own database (issue 1518, 1427;
not yet built).
mail seats, serving twelve verbs (ADR 0459 §2, ADR 0517 §4). Each answers only a caller the trusted setting
`callers` names (default `["mesh-mail-web"]`); any other caller is refused, naming why.
- **Hears mesh-mail's added events** and judges a batch of up to 6 at a time (ADR 0516 §3) against a question
put to `claude_code_judge`, keeping one entry per message — never its body — in its own database: one
Gmail message in INBOX and All Mail is one entry, by its Message-ID, else its folder and UID (issue 1849).
- **Notes what each mail asks** (ADR 0516, issue 1857): at most 3 tasks (a line and a due date), 3 promises
(you promised, waiting on others, on its way; who and a date) and whether it is read later, each line the
model's own words of at most 120 characters; a line sharing 8 words in a row with the mail fails the batch.
- **Reads the last 14 days once more** after delivery (ADR 0516 §5): the inbox mail of each granted account,
in ordinary batches after any mail just heard; kept as done, and the journal says its start, end and count.
- **Keeps the operator's handling and an action log** (ADR 0517, issue 1858): an item (a task, a promise or
the mail itself) handled, undone until the day ends, or reminded until the next day (Europe/Brussels); one
line per act — what, mail id, item, mode, when, never mail text — kept 365 days, removed each night.
- **Keeps a health of its own**, `mail_reader_health`: unhealthy once a judgement is refused, times out or
answers out of shape; healthy again once one succeeds (issue 1519; not yet built).
answers out of shape; healthy once one succeeds, saying when, or that no batch has run yet (issue 1580).
- **Tells the operator** at most once a batch when a mail matters, and corrects itself at once, keeping a
reading lesson, when the operator says it judged wrong (issue 1428, 1429, 1522; not yet built).
Until each of those issues lands, its own verb answers that it is not built yet, once the caller check above
has already passed or refused.
reading lesson, when the operator says it judged wrong (issue 1428, 1429, 1522).
## Its database
Its own `postgres-database` (issue 1517; not yet declared in the manifest until then): mail entries, mail
groups, reading lessons, the mails heard but not yet read, and its own health state. No column holds a mail
body or text taken from one.
Its own `postgres-database`: mail entries with their tasks and promises, mail groups, reading lessons, the
mails heard but not yet read, its health state, whether the 14-day re-reading was done, each item's handling
and the action log. No column holds a mail body or text taken from one. The schema is numbered migrations
(`migrations/`) and the consolidated `schema.sql`; a unit test proves the two agree.
## Tools
@@ -34,11 +39,17 @@ body or text taken from one.
|---|---|
| `mail-reading.entries` | every mail entry: headers, mail group, summary, mark and why, due date. Read-only |
| `mail-reading.groups` | every mail group a batch has made, largest first. Read-only |
| `mail-reading.correct` | correct one entry's mark, why or due date; kept at once as a reading lesson |
| `mail-reading.correct` | correct one entry's mark, mail group, why or due date, say a task is not one, remove a promise, set or clear read later; kept at once as a reading lesson, with one corrected line |
| `mail-reading.lessons` | the reading lessons kept, at most 40. Read-only |
| `mail-reading.undo` | undo one reading lesson |
| `mail-reading.status` | lessons kept, undone, and per 100 mails read this week. Read-only |
| `mail_reader_health` | how mesh-mail-reader is: unhealthy once a judgement is refused, times out or answers out of shape, naming which and since when; healthy once one succeeds. Read-only |
| `mail-reading.status` | healthy or not, when a batch last succeeded (or that none has run yet), lessons per 100 mails read each week. Read-only |
| `mail-reading.list` | the operator's list for a mode: today, this week, later, the promises by kind, good to know, read later, handled today, scam warnings, and when the operator last looked. Read-only |
| `mail-reading.handle` | mark an item handled (the mail itself handles all its items); one handled line |
| `mail-reading.unhandle` | undo a handling of today; one undone line |
| `mail-reading.remind` | hide an item until the next day, when it is under today; one reminded line |
| `mail-reading.record` | one line for an act that changes no entry: opened, mode, page opened |
| `mail-reading.actions` | the action log, newest first. Read-only |
| `mail_reader_health` | how mesh-mail-reader is: unhealthy once a judgement is refused, times out or answers out of shape, naming which and since when; healthy, saying when the last batch succeeded or that none has run yet. Read-only |
## Where it runs
@@ -0,0 +1,143 @@
package main
// Issue 1858 (novox/hq ADR 0517 §4, §5): correct writes its corrected line beside its reading lesson, in one
// transaction; no code but the acts writes the action log; and every new verb answers only a caller the
// operator grants it.
import (
"context"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 1858/AC4: correct writes exactly one corrected line — its mail id, the item it corrected, the mode and when.
func TestCorrectWritesOneCorrectedLine(t *testing.T) {
store := newMemStore()
id := entryWithItems(t, store)
now := brusselsAt(2026, 10, 22, 9, 0)
b := listBundle(store, &now)
for i, tc := range []struct {
args map[string]any
item string
}{
{map[string]any{"mark": "neither", "mode": "work"}, ItemMail},
{map[string]any{"not-a-task": float64(2), "mode": "work"}, "task 2"},
{map[string]any{"remove-promise": float64(1), "mode": "work"}, "promise 1"},
} {
tc.args["id"] = float64(id)
if _, err := b.correctTool(tc.args); err != nil {
t.Fatal(err)
}
got := lines(t, store)
want := ActionLine{ID: got[0].ID, What: ActCorrected, Entry: id, Item: tc.item, Mode: "work", At: now}
if len(got) != i+1 || got[0] != want {
t.Fatalf("after correct %v: lines %+v, want %d, the newest %+v", tc.args, got, i+1, want)
}
}
}
// 1858/AC4: a correction that fails in the store keeps no lesson, writes no line and leaves the entry.
func TestAFailedCorrectionWritesNothing(t *testing.T) {
ctx := context.Background()
store := newMemStore()
id := entryWithItems(t, store)
store.failAct = errStoreFake
if _, err := testBundle(store, "mesh-mail-web").correctTool(map[string]any{"id": float64(id), "mark": "neither"}); err == nil {
t.Fatal("correct with a failing store: no error")
}
e, _, _ := store.Entry(ctx, id)
lessons, _ := store.Lessons(ctx)
if e.Mark != MarkMatters || len(lessons) != 0 || len(lines(t, store)) != 0 {
t.Fatalf("a failed correction left mark %q, lessons %+v, lines %+v", e.Mark, lessons, lines(t, store))
}
}
// 1858/AC4: no other code writes the action log. The store's writes (Act, Record, CorrectEntry) are called by
// the verbs alone, each act by its own verb; the one SQL statement that inserts a line is insertLine's,
// called by the store's three writes alone.
func TestOnlyTheActsWriteTheActionLog(t *testing.T) {
allowed := map[string]map[string]bool{
"Act": {"act": true},
"Record": {"recordTool": true},
"CorrectEntry": {"correctTool": true},
"insertLine": {"Act": true, "Record": true, "CorrectEntry": true},
}
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
fset := token.NewFileSet()
for _, name := range files {
if strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if n := strings.Count(string(src), "insert into action_line"); n > 0 && (name != "pgstore.go" || n != 1) {
t.Fatalf("%s inserts into action_line %d time(s); only insertLine, in pgstore.go, may", name, n)
}
file, err := parser.ParseFile(fset, name, src, 0)
if err != nil {
t.Fatal(err)
}
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
var called string
switch f := call.Fun.(type) {
case *ast.SelectorExpr:
called = f.Sel.Name
case *ast.Ident:
called = f.Name
}
if callers, watched := allowed[called]; watched && !callers[fn.Name.Name] {
t.Errorf("%s: %s calls %s, which writes the action log; only %v may", name, fn.Name.Name, called, callers)
}
return true
})
}
}
}
// 1858/AC8: a caller the operator did not grant is refused each new verb, before anything else.
func TestTheNewVerbsCheckTheirCaller(t *testing.T) {
store := newMemStore()
id := entryWithItems(t, store)
now := time.Now()
for _, verb := range []struct {
name string
args map[string]any
}{
{ListTool, map[string]any{}},
{HandleTool, map[string]any{"id": float64(id)}},
{UnhandleTool, map[string]any{"id": float64(id)}},
{RemindTool, map[string]any{"id": float64(id)}},
{RecordTool, map[string]any{"what": "page opened"}},
{ActionsTool, map[string]any{}},
} {
b := listBundle(store, &now)
b.Caller = func(map[string]any) string { return "person.someoneelse" }
_, err := callTool(t, b, verb.name, verb.args)
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "person.someoneelse") {
t.Errorf("%s by an ungranted caller: %v, want a refusal naming it", verb.name, err)
}
}
if got := lines(t, store); len(got) != 0 {
t.Fatalf("refused callers wrote %+v", got)
}
}
@@ -2,7 +2,7 @@ package main
// Callers is the trusted setting `callers` (lead-m2@g14's decision at this milestone's prepare step, said to
// the operator; novox/hq ADR 0459 §2: "each answers only a caller the operator grants it"): who may call one
// of mail-reading's six verbs. An entry is a bare module name (granted wherever it runs, on any machine),
// of mail-reading's verbs. An entry is a bare module name (granted wherever it runs, on any machine),
// exactly <machine>.<module>, or exactly person.<name> — the same shapes mesh-mail's own trusted setting
// `grants` matches (modules/mesh-mail/cmd/mesh-mail/config.go, Grants.Allows), copied here in miniature (one
// flat list, not one per account) so mesh-mail-reader never imports mesh-mail. Nobody beyond the list is
@@ -1,12 +1,12 @@
// mesh-mail-reader: the default claimant of mail-reading (novox/hq ADR 0459 §1), built from mesh-mail's own
// source, like mesh-mail-web and mesh-mail-notifier. One instance, held once for the mesh, on the machine
// mesh-mail runs on. It hears mesh-mail's added events under its own runs-as account (ADR 0459 §3, issue
// 1518), judges a batch of them at a time (1427, 1520), and serves the six mail-reading verbs (entries,
// groups, correct, lessons, undo, status) and its own health tool, mail_reader_health (1519). It reads mail;
// it moves, flags, deletes and sends nothing (novox/hq ADR 0459 §11).
// 1518), judges a batch of them at a time (1427, 1520), and serves the twelve mail-reading verbs (entries,
// groups, correct, lessons, undo, status; list, handle, unhandle, remind, record, actions, issue 1858) and
// its own health tool, mail_reader_health (1519). It reads mail; it moves, flags, deletes and sends nothing (novox/hq ADR 0459 §11).
//
// mesh-mail-reader the bundle the node's runtime launches: the hearing loop (hear.go), the
// batch loop (batch.go) and the six mail-reading verbs plus mail_reader_health,
// batch loop (batch.go) and the mail-reading verbs plus mail_reader_health,
// served over stdio (tools.go)
// mesh-mail-reader prepare the preparation step: the index's schema (store.go, pgstore.go) brought to
// this version's (issue 1517)
@@ -88,7 +88,7 @@ func bundleAsk(key string, body map[string]any) (json.RawMessage, error) { retur
// batchLoop runs the reader's batches (RunBatches, batch.go) against the module's own store, judging on this
// machine (MESH_NODE, the lead's decision at this milestone's prepare step). Started before stdio.ServeOn,
// exactly as hearLoop is: a batch failure must never take the six mail-reading verbs or mail_reader_health
// exactly as hearLoop is: a batch failure must never take the mail-reading verbs or mail_reader_health
// down with it (RunBatches logs and keeps going; see its own doc comment).
func batchLoop(storeFor func(context.Context) (Store, error)) {
store, err := storeFor(context.Background())
@@ -139,7 +139,7 @@ const hearFirstRetryWait = 250 * time.Millisecond
// hearLoop subscribes to mesh-mail's added events and hears each one (hear.go), mirroring mesh-mail-notifier's
// watchWith (issue 1518): a subscribe the runtime refuses is retried, starting at hearFirstRetryWait and
// doubling to at most a minute, logging each refusal, never fatal — a refusal here must never take the six
// doubling to at most a minute, logging each refusal, never fatal — a refusal here must never take the
// mail-reading verbs or mail_reader_health down with it. Started before stdio.ServeOn, so it keeps running
// whatever stdio.ServeOn does with the stream it is given.
func hearLoop(storeFor func(context.Context) (Store, error), subscribe func(string, func(stdio.Envelope) error) error, sleep func(time.Duration)) {
@@ -1,13 +1,15 @@
package main
// The module's tools (novox/hq ADR 0459 §2, issue 1426): the six mail-reading seat verbs — entries, groups,
// correct, lessons, undo, status — and the module's own health tool, mail_reader_health (1519). Who calls is
// The module's tools (novox/hq ADR 0459 §2, issue 1426; ADR 0517 §4, issue 1858): the mail-reading seat
// verbs — entries, groups, correct, lessons, undo, status, list, handle, unhandle, remind, record, actions —
// and the module's own health tool, mail_reader_health (1519). Who calls is
// the runtime's word (stdio.CallerOf), never an argument; a mail-reading verb answers only a caller the
// trusted setting `callers` names (callers.go), and refuses any other, naming why. mail_reader_health is
// asked by the node-engine itself and carries no caller check.
//
// The six verbs' own behaviour — reading and writing mail entries, mail groups and reading lessons — is
// 1521 (entries, groups, status) and 1428 (correct, lessons, undo): all six now answer for real.
// 1521 (entries, groups, status) and 1428 (correct, lessons, undo): all six now answer for real. The operator's
// list and what is done with it — list, handle, unhandle, remind, record, actions — are 1858's (listverbs.go).
// mail_reader_health's own answer is 1519's: it reaches the kept health state (store.go, health.go).
import (
@@ -20,7 +22,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Seat tool names (novox/hq ADR 0459 §2): mesh-mail-reader claims the mail-reading seat and serves its six
// Seat tool names (novox/hq ADR 0459 §2): mesh-mail-reader claims the mail-reading seat and serves its
// verbs through these tool names, exactly as mesh-mail's own mail-store seat tools are named "mail-store.<verb>".
const (
EntriesTool = "mail-reading.entries"
@@ -133,7 +135,7 @@ func only(args map[string]any, takes ...string) error {
return nil
}
// Tools are the module's tools (1426/AC3): the six mail-reading verbs and mail_reader_health, each with its
// Tools are the module's tools (1426/AC3): the mail-reading verbs and mail_reader_health, each with its
// own argument schema.
func (b *Bundle) Tools() []stdio.Tool {
return []stdio.Tool{
@@ -160,7 +162,8 @@ func (b *Bundle) Tools() []stdio.Tool {
Description: "Correct one mail entry's mark, mail group, why or due date, say one of its tasks is not a " +
"task, remove one of its promises, or set or clear read later (novox/hq ADR 0459 §8, ADR 0516 §4): " +
"kept at once as a reading lesson (the mail's own headers, the change made, when), no operator " +
"Approve needed, and outranking that mail's own words in any later batch. Refused for an unknown " +
"Approve needed, and outranking that mail's own words in any later batch; one corrected line in the " +
"action log (ADR 0517 §5). Refused for an unknown " +
"mail id or mark. Answered only to a caller the setting callers names.",
Input: schema([]string{"id"}, map[string]any{
"id": integer("the mail entry's id, as entries answered it"),
@@ -171,6 +174,7 @@ func (b *Bundle) Tools() []stdio.Tool {
"not-a-task": integer("the number of a task of this entry that is not a task"),
"remove-promise": integer("the number of a promise of this entry to remove"),
"read-later": boolean("whether the mail is read later"),
"mode": text("the mode the page is in, for the corrected line: work, personal or everything (when left out)"),
}),
Run: b.correctTool},
{Name: LessonsTool,
@@ -377,7 +381,11 @@ func (b *Bundle) correctTool(args map[string]any) (any, error) {
if _, err := b.checkCaller(args); err != nil {
return nil, err
}
if err := only(args, "id", "mark", "group", "why", "due-date", "not-a-task", "remove-promise", "read-later"); err != nil {
if err := only(args, "id", "mark", "group", "why", "due-date", "not-a-task", "remove-promise", "read-later", "mode"); err != nil {
return nil, err
}
mode, err := modeArg(args)
if err != nil {
return nil, err
}
id, err := number(args, "id", 0)
@@ -458,16 +466,26 @@ func (b *Bundle) correctTool(args map[string]any) (any, error) {
if change == "" {
return nil, fmt.Errorf("%s needs a mark, mail group, why, due-date, not-a-task, remove-promise or read-later that actually changes the entry", CorrectTool)
}
if err := store.UpdateEntry(ctx, updated); err != nil {
return nil, err
}
// Kept at once, no operator Approve (ADR 0459 §8): the mail's own headers, never its body, the change made
// and when. 1428/AC1.
// and when (1428/AC1); with the corrected line of the action log, in the same transaction as the entry's
// change and the lesson (ADR 0517 §5, 1858/AC4): the item corrected, or the mail itself.
now := b.now()
lesson := Lesson{
Account: entry.Account, Folder: entry.Folder, SenderName: entry.SenderName, SenderAddress: entry.SenderAddress,
Subject: entry.Subject, Group: updated.Group, Change: change, Made: time.Now(),
Subject: entry.Subject, Group: updated.Group, Change: change, Made: now,
}
if _, err := store.AddLesson(ctx, lesson); err != nil {
item := ItemMail
switch {
case c.NotATask != 0 && c.RemovePromise == 0:
item = fmt.Sprintf("task %d", c.NotATask)
case c.RemovePromise != 0 && c.NotATask == 0:
item = fmt.Sprintf("promise %d", c.RemovePromise)
}
line := ActionLine{What: ActCorrected, Entry: entry.ID, Item: item, Mode: mode, At: now}
if _, err := store.CorrectEntry(ctx, updated, lesson, line); err != nil {
if err == ErrNotFound {
return nil, fmt.Errorf("%s: no mail entry %d is held; entries answers the ids held", CorrectTool, id)
}
return nil, err
}
newLabels, err := groupLabels(ctx, store)
@@ -21,7 +21,10 @@ func TestToolsListsTheSixVerbsAndHealth(t *testing.T) {
}
}
want := []string{
"mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", "mail-reading.undo", "mail-reading.status", "mail-reading.list", "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind", "mail-reading.record", "mail-reading.actions", "mail_reader_health",
"mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons",
"mail-reading.undo", "mail-reading.status", "mail-reading.list", "mail-reading.handle",
"mail-reading.unhandle", "mail-reading.remind", "mail-reading.record", "mail-reading.actions",
"mail_reader_health",
}
if len(names) != len(want) {
t.Fatalf("Tools() = %v, want %v", names, want)
+1 -1
View File
@@ -36,7 +36,7 @@
"config": {
"callers": {
"type": "list",
"description": "Who may call one of mail-reading's six verbs (entries, groups, correct, lessons, undo, status): a module's name (on every machine), <machine>.<module>, or person.<name> (novox/hq ADR 0459 §2). mesh-mail-web alone by default.",
"description": "Who may call one of mail-reading's verbs (entries, groups, correct, lessons, undo, status, list, handle, unhandle, remind, record, actions): a module's name (on every machine), <machine>.<module>, or person.<name> (novox/hq ADR 0459 §2). mesh-mail-web alone by default.",
"trusted": true,
"default": ["mesh-mail-web"]
}