Answer a send's state only to its caller (issue 1261)

This commit is contained in:
jochen
2026-10-19 21:44:12 +02:00
parent 66aa204f67
commit a9ff48e200
5 changed files with 204 additions and 22 deletions
@@ -94,7 +94,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
}
wantTools := []string{
"mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status",
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send",
"mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", "mail-store.send-status",
}
if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) {
t.Fatalf("tools %v, the code serves %v", m.Tools, names)
@@ -140,7 +140,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
// mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier
// there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3).
wantSeats := []seat{
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}},
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}},
{Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}},
}
@@ -148,7 +148,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats)
}
wantClaims := []seat{
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}},
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
}
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
+23 -10
View File
@@ -1312,19 +1312,30 @@ func tellWaits(store SendStore, tell TellOperator, id string, now time.Time) err
// ---- reading the status -----------------------------------------------------------------------------------------
// ReadSendStatus is where a send asked with mail_send stands, by its ask id: waiting for the operator, sent,
// declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own
// record; when the router's word is a close one that was not decided yet, this is also where a send is first
// decided (and so sent), exactly once (Decide).
func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id string, now time.Time) (map[string]any, error) {
// ReadSendStatus is where a send asked with mail_send stands, answered only to the caller that asked for it
// (novox/hq ADR 0440 §7, issue 1261): an id no send is kept under, or a send whose own caller differs from the
// one asking, answers status "unknown" and nothing else of it — never an error, and never the account,
// recipients or subject of a send that is not the caller's own. The sweep, which settles a send regardless of
// who asked for it, calls readSendStatus directly instead, never through this caller check.
func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id, caller string, now time.Time) (map[string]any, error) {
trimmed := strings.TrimSpace(id)
req, rev, ok, err := getSend(store, trimmed)
if err != nil {
return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err)
}
if !ok {
return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed)
if !ok || req.Caller != caller {
return map[string]any{"status": "unknown"}, nil
}
return readSendStatus(store, bus, publish, req, rev, now)
}
// readSendStatus is ReadSendStatus's own reading of one send already fetched (req, rev): waiting for the
// operator, sent, declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the
// router's own record; when the router's word is a close one that was not decided yet, this is also where a
// send is first decided (and so sent), exactly once (Decide). It never checks who is asking: ReadSendStatus
// does that before calling it, and the sweep (which settles a send no matter who asked for it) calls it
// directly.
func readSendStatus(store SendStore, bus AskBus, publish QueuePublish, req SendRequest, rev uint64, now time.Time) (map[string]any, error) {
out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject,
"asked": req.At.Format(time.RFC3339)}
if req.Decided != nil {
@@ -1342,7 +1353,7 @@ func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id string
if now.Sub(req.At) > SendAskLasts {
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "ended: never heard"})
if err != nil {
return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", trimmed, err)
return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", req.ID, err)
}
reportDecision(out, d, now)
return out, nil
@@ -1448,11 +1459,13 @@ func (b *Bundle) Sweep(now time.Time) {
}
} else {
for _, id := range undecided {
req, _, ok, err := getSend(store, id)
req, rev, ok, err := getSend(store, id)
if err != nil || !ok || req.Decided != nil {
continue
}
if _, err := ReadSendStatus(store, b.askBus(), b.queuePublish(), id, now); err != nil {
// 1261/AC6: the sweep settles a send regardless of who asked for it — readSendStatus directly,
// never ReadSendStatus's caller check.
if _, err := readSendStatus(store, b.askBus(), b.queuePublish(), req, rev, now); err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] sweeping the send %s: %v", id, err)
}
@@ -861,3 +861,163 @@ func TestADigestMismatchIsRefusedAndNothingIsQueued(t *testing.T) {
t.Fatalf("the queue received %d message(s) despite the digest mismatch", n)
}
}
// ---- mail_send_status answers only its caller (novox/hq ADR 0440 §7, issue 1261) ---------------------------------
// 1261/AC1: the caller that made a send reads its own status in full, with its account, recipients and subject
// as before.
func TestSendStatusAnswersTheCallersOwnSendInFull(t *testing.T) {
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if isErr {
t.Fatalf("%s", text)
}
var out map[string]any
_ = json.Unmarshal([]byte(text), &out)
id := out["ask"].(string)
text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
if isErr {
t.Fatalf("%s", text)
}
var status map[string]any
if err := json.Unmarshal([]byte(text), &status); err != nil {
t.Fatal(err)
}
if status["status"] != "waiting" || status["account"] != "mesh" || status["subject"] != sendArgs["subject"] {
t.Fatalf("the caller's own send: %v", status)
}
to, _ := status["to"].([]any)
if len(to) != 2 {
t.Fatalf("to: %v", status["to"])
}
}
// 1261/AC2: a caller other than the one that made the send reads status unknown and nothing else of it — no
// account, recipients or subject, and no error either.
func TestSendStatusAnswersUnknownAndNothingElseToAnotherCaller(t *testing.T) {
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if isErr {
t.Fatalf("%s", text)
}
var out map[string]any
_ = json.Unmarshal([]byte(text), &out)
id := out["ask"].(string)
text, isErr = rt.call(SendStatusTool, "novox.someone-else", map[string]any{"id": id})
if isErr {
t.Fatalf("another caller's read of the send was an error: %s", text)
}
var status map[string]any
if err := json.Unmarshal([]byte(text), &status); err != nil {
t.Fatal(err)
}
if len(status) != 1 || status["status"] != "unknown" {
t.Fatalf("another caller's read of the send: %v, want only status unknown", status)
}
}
// 1261/AC3: an id no send is kept under answers status unknown and no error, for any caller.
func TestSendStatusAnswersUnknownForAnUnknownIDWithoutAnError(t *testing.T) {
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": "send-0000000000000000"})
if isErr {
t.Fatalf("an unknown id was an error: %s", text)
}
var status map[string]any
if err := json.Unmarshal([]byte(text), &status); err != nil {
t.Fatal(err)
}
if len(status) != 1 || status["status"] != "unknown" {
t.Fatalf("%v", status)
}
}
// 1261/AC4: every kept decision's status word is one of the fixed vocabulary (waiting, sent, failed, refused,
// not proven, declined, expired), answered to the send's own caller.
func TestSendStatusWordsAreTheFixedVocabularyForTheCallersOwnSend(t *testing.T) {
sends := newMemSends()
const caller = "novox.triage"
cases := []struct {
name, outcome, want string
}{
{"queued", "queued", "waiting"},
{"sending", "sending", "waiting"},
{"retrying", "retrying", "waiting"},
{"sent", "sent", "sent"},
{"declined", "declined", "declined"},
{"not-proven", "not proven", "not proven"},
{"failed", "failed: boom", "failed"},
{"refused", "refused: nope", "refused"},
{"ended-never-heard", "ended: never heard", "expired"},
{"ended-other", "ended: cancelled", "expired"},
}
for _, c := range cases {
id := "send-" + c.name
req := SendRequest{ID: id, Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Caller: caller,
At: judgedAt, Decided: &SendDecision{At: judgedAt, Outcome: c.outcome}}
if _, err := sends.Create(id, req); err != nil {
t.Fatalf("%s: keeping: %v", c.name, err)
}
out, err := ReadSendStatus(sends, nil, nil, id, caller, judgedAt)
if err != nil {
t.Fatalf("%s: %v", c.name, err)
}
if out["status"] != c.want {
t.Fatalf("%s: status %v, want %v", c.name, out["status"], c.want)
}
}
}
// 1261/AC5: the seat verb mail-store.send-status answers exactly as mail_send_status, for the same caller.
func TestSeatSendStatusAnswersExactlyAsMailSendStatus(t *testing.T) {
b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if isErr {
t.Fatalf("%s", text)
}
var out map[string]any
_ = json.Unmarshal([]byte(text), &out)
id := out["ask"].(string)
toolText, toolErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id})
seatText, seatErr := rt.call(SeatSendStatusTool, "novox.triage", map[string]any{"id": id})
if toolErr != seatErr || toolText != seatText {
t.Fatalf("mail_send_status: %v %s; mail-store.send-status: %v %s", toolErr, toolText, seatErr, seatText)
}
}
// 1261/AC6: the sweep settles a send left undecided no matter who asked for it — never refused by the caller
// check mail_send_status itself applies.
func TestSweepSettlesASendRegardlessOfCaller(t *testing.T) {
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
queue := newMemQueue()
b.Queue = queue.publish
rt := startBundle(t, b.Tools())
text, isErr := rt.call(SendTool, "novox.triage", sendArgs)
if isErr {
t.Fatalf("%s", text)
}
var out map[string]any
_ = json.Unmarshal([]byte(text), &out)
id := out["ask"].(string)
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
router.close(w) // only the router's record holds the answer
// The sweep itself names no caller at all; it must still settle "novox.triage"'s send.
b.Sweep(judgedAt.Add(2 * time.Minute))
if n := queue.count(); n != 1 {
t.Fatalf("the sweep did not settle the send regardless of caller: %d message(s)", n)
}
}
+14 -6
View File
@@ -30,10 +30,11 @@ const (
// its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants
// reach the same account's mail through, whichever name it calls.
const (
SeatAccountsTool = "mail-store.accounts"
SeatSearchTool = "mail-store.search"
SeatReadTool = "mail-store.read"
SeatSendTool = "mail-store.send"
SeatAccountsTool = "mail-store.accounts"
SeatSearchTool = "mail-store.search"
SeatReadTool = "mail-store.read"
SeatSendTool = "mail-store.send"
SeatSendStatusTool = "mail-store.send-status"
)
// Bounds of what a tool answers.
@@ -212,7 +213,9 @@ func (b *Bundle) Tools() []stdio.Tool {
{Name: SendStatusTool,
Description: "Where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, declined, " +
"ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own record, held " +
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here).",
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here). " +
"Answered only to the caller that asked for the send: an id this caller did not ask for, known or not, " +
"answers status unknown and nothing else of it (novox/hq ADR 0440 §7, issue 1261).",
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
Run: b.sendStatusTool},
{Name: SeatAccountsTool,
@@ -242,6 +245,10 @@ func (b *Bundle) Tools() []stdio.Tool {
"body": text("the message's plain-text body"),
}),
Run: b.sendTool},
{Name: SeatSendStatusTool,
Description: "The mail-store seat's send-status verb (novox/hq ADR 0378 §2, issue 1261): the same as mail_send_status.",
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
Run: b.sendStatusTool},
}
}
@@ -612,7 +619,8 @@ func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {
if id == "" {
return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool)
}
return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, b.now())
caller := b.caller(args)
return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, caller, b.now())
}
// ---- arguments ----------------------------------------------------------------------------------------------------
+4 -3
View File
@@ -6,7 +6,7 @@
{
"name": "mail-store",
"scope": "mesh",
"serves": ["accounts", "search", "read", "send"]
"serves": ["accounts", "search", "read", "send", "send-status"]
},
{
"name": "mail-webclient",
@@ -24,7 +24,7 @@
{
"name": "mail-store",
"scope": "mesh",
"serves": ["accounts", "search", "read", "send"]
"serves": ["accounts", "search", "read", "send", "send-status"]
}
],
"requires": [
@@ -80,7 +80,8 @@
"mail-store.accounts",
"mail-store.search",
"mail-store.read",
"mail-store.send"
"mail-store.send",
"mail-store.send-status"
],
"uses": [
"operator-channel"