mesh-mail: on Approve the tools record the send queued directly, with no separate approved state, then publish; a stuck queued record is republished by the sweep past a short grace, never NakWithDelay'd by the sender (ADR 0436 clause 8, decided to replace the review round 1 item 2 work-around; issues 1194, 1196, 1199)

This commit is contained in:
jochen
2026-10-19 10:53:32 +02:00
parent 7397051771
commit 1639269ad4
5 changed files with 169 additions and 171 deletions
+48 -46
View File
@@ -224,46 +224,6 @@ func TestClaimQueuedSendOnALostRaceSendsNothingAndLeavesTheMessageToTheBus(t *te
}
}
// 1196/AC5: given a send still approved (publishQueued published to mail-sends before it recorded queued,
// and a message was delivered inside that window), when taken, then it is delivered again later and
// nothing is recorded or told (novox/hq review round 1 finding 2).
func TestClaimQueuedSendOnASendStillApprovedAsksRedeliveryAndRecordsNothing(t *testing.T) {
sends := newMemSends()
req := rawQueuedSend(t, sends)
current, rev, ok, err := getSend(sends, req.ID)
if err != nil || !ok {
t.Fatalf("reading it back: %v %v", err, ok)
}
current.Decided.Outcome = "approved" // not yet advanced to "queued" by the tools' own second write
if _, err := sends.Update(req.ID, current, rev); err != nil {
t.Fatalf("leaving it approved: %v", err)
}
req = current
emit := &memEmit{}
_, d, _, outcome, err := claimQueuedSend(sends, newSendRouter().Record, emit.emit, req.ID, judgedAt.Add(3*time.Minute))
if err != nil {
t.Fatalf("claiming: %v", err)
}
if outcome != claimNotYetQueued {
t.Fatalf("outcome: %v, want claimNotYetQueued", outcome)
}
if d.Outcome != "approved" {
t.Fatalf("decision: %+v, want outcome still approved (unchanged)", d)
}
kept, _, ok, err := getSend(sends, req.ID)
if err != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "approved" || kept.Decided.Tell != "" {
t.Fatalf("something was recorded or told: %v %v %+v", err, ok, kept.Decided)
}
ids, err := tellPendingIDs(sends)
if err != nil || len(ids) != 0 {
t.Fatalf("a tell was queued despite nothing being told: %v %v", err, ids)
}
if len(emit.calls) != 0 {
t.Fatalf("a send still approved emitted something: %+v", emit.calls)
}
}
// review round 1 finding 3: a failed write of the tells-pending index does not stop the refusal itself
// from being recorded — the record write and the message's own fate are never undone by an index that
// could not be kept — but the index failure is still reported, never silently dropped.
@@ -316,21 +276,21 @@ func TestClaimQueuedSendStillRecordsNotProvenWhenTheTellsPendingIndexCannotBeWri
assertEmittedFailed(t, emit, req.ID, d.Outcome)
}
// Same finding, for publishQueued: a failed sender-pending index write does not stop "queued" from being
// recorded, since the bus already took the message once.
func TestPublishQueuedStillRecordsQueuedWhenTheSenderPendingIndexCannotBeWritten(t *testing.T) {
// Same finding, for decideSend: a failed sender-pending index write does not stop "queued" from being
// recorded, nor the publish from being attempted — there is no separate "approved" state any more (novox/hq
// ADR 0436 clause 8, as decided to replace the two-state design, review round 1).
func TestDecideSendStillRecordsQueuedAndPublishesWhenTheSenderPendingIndexCannotBeWritten(t *testing.T) {
sends := newMemSends()
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
claim := SendDecision{At: judgedAt, Outcome: "approved"}
req.Decided = &claim
rev, err := sends.Create(req.ID, req)
if err != nil {
t.Fatal(err)
}
sends.failKey, sends.failErr = senderPendingKey, errors.New("the store is down")
queue := newMemQueue()
w := warrantFor(req, judgedAt.Add(time.Minute))
d, err := publishQueued(sends, req, rev, claim, queue.publish, judgedAt.Add(time.Minute))
d, err := decideSend(sends, queue.publish, req, rev, w, judgedAt.Add(time.Minute))
if d.Outcome != "queued" {
t.Fatalf("decision: %+v, want queued despite the index failing", d)
}
@@ -340,6 +300,48 @@ func TestPublishQueuedStillRecordsQueuedWhenTheSenderPendingIndexCannotBeWritten
if n := queue.count(); n != 1 {
t.Fatalf("published %d time(s)", n)
}
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
t.Fatalf("not kept as queued despite the index failing: %v %v %+v", kerr, ok, kept.Decided)
}
}
// 1194/AC1 (as decided to replace the two-state design, review round 1): on a verified warrant the record
// moves straight from undecided to queued by one compare-and-set, with its time, and mail.send.approved is
// published with the send id as its message id — no "approved" state in between.
func TestDecideSendRecordsQueuedDirectlyAndPublishes(t *testing.T) {
sends := newMemSends()
req := SendRequest{ID: newSendID(), Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Body: "body", At: judgedAt}
rev, err := sends.Create(req.ID, req)
if err != nil {
t.Fatal(err)
}
queue := newMemQueue()
w := warrantFor(req, judgedAt.Add(time.Minute))
now := judgedAt.Add(time.Minute)
d, err := decideSend(sends, queue.publish, req, rev, w, now)
if err != nil {
t.Fatalf("deciding: %v", err)
}
if d.Outcome != "queued" || !d.At.Equal(now) {
t.Fatalf("decision: %+v, want queued at %v", d, now)
}
if n := queue.count(); n != 1 {
t.Fatalf("published %d time(s), want exactly 1", n)
}
got := queue.last()
if got.id != req.ID || got.body.Send != req.ID || got.body.Account != "mesh" || got.body.Ask != req.ID {
t.Fatalf("published %+v, want the send id as the message id and body", got)
}
kept, _, ok, kerr := getSend(sends, req.ID)
if kerr != nil || !ok || kept.Decided == nil || kept.Decided.Outcome != "queued" {
t.Fatalf("not kept as queued: %v %v %+v", kerr, ok, kept.Decided)
}
ids, err := senderPendingIDs(sends)
if err != nil || len(ids) != 1 || ids[0] != req.ID {
t.Fatalf("not indexed as sender-pending: %v %v", err, ids)
}
}
// raceOnFirstUpdate performs one competing write the first time Update is called — simulating another
+69 -82
View File
@@ -591,8 +591,7 @@ func sdkQueuePublish(id string, body any) error {
// updateDecision writes a decision onto a kept send by compare-and-set, without touching the undecided index:
// the index is settleSend's to drop, once the decision is one the sweep no longer needs to look at again
// (novox/hq issue 811; ADR 0436 clause 8, where "approved" still needs the sweep). On a race lost to another
// writer, it reads back whatever that writer decided.
// (novox/hq issue 811). On a race lost to another writer, it reads back whatever that writer decided.
func updateDecision(store SendStore, req SendRequest, rev uint64, d SendDecision) (SendDecision, uint64, error) {
next := req
next.Decided = &d
@@ -671,52 +670,35 @@ func Decide(store SendStore, publish QueuePublish, w asks.Warrant, now time.Time
}
}
// decideSend claims the request as approved by a compare-and-set write before it is queued, so a redelivered
// decideSend records the request queued by a compare-and-set write before it publishes, so a redelivered
// warrant finds it already claimed (or decided) and queues nothing twice: a person's Approve is queued once
// (novox/hq ADR 0436 clause 8). It never sends: the sender (clause 7) takes the queued message, verifies the
// warrant again itself (clause 9) and performs the one handover to the server (clause 10).
// (novox/hq ADR 0436 clause 8, as decided to replace the two-state approved/queued design — review round 1,
// replacing finding 2's work-around with a decision changing the clause itself). There is no "approved"
// state in a send's life at all: the record moves straight from undecided to queued; the publish that
// follows is best-effort, and a publish that fails (or the tools stopping before it ever runs) leaves the
// record "queued" with its own time, which the sweep finds and republishes once a short grace has passed
// (Sweep, below) — the send id as the message id, and the stream's duplicate window, keep it one message
// either way, and the sender's own claim (clause 9) keeps it one send. It never sends: the sender (clause 7)
// takes the queued message, verifies the warrant again itself (clause 9) and performs the one handover to
// the server (clause 10).
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) {
claim := SendDecision{At: now, Outcome: "approved", Warrant: w.Says()}
claimed, newRev, err := updateDecision(store, req, rev, claim)
// Indexed before the record carries "queued" (novox/hq review round 1 finding 3): a crash between this
// and the write below must still leave it findable by mail_health's overdue check and the sweep's own
// stale-queued retry once "queued" does land.
indexErr := addSenderPending(store, req.ID)
queued := SendDecision{At: now, Outcome: "queued", Warrant: w.Says()}
claimed, newRev, err := settleSend(store, req, rev, queued)
if err != nil {
return SendDecision{}, err
return SendDecision{}, errOr(indexErr, err)
}
if newRev == 0 {
// Lost the claim race: another writer already decided (or is queueing) this send.
return claimed, nil
// Lost the claim race: another writer already decided this send.
return claimed, indexErr
}
return publishQueued(store, req, newRev, claimed, publish, now)
}
// publishQueued publishes the approved send on mail-sends, the send id its own message id, and records queued
// once the bus took it. A publish that fails leaves the record approved — never an error returned to the
// caller — so the sweep finds it and publishes it again (novox/hq ADR 0436 clause 8).
func publishQueued(store SendStore, req SendRequest, rev uint64, approved SendDecision, publish QueuePublish, now time.Time) (SendDecision, error) {
if err := publish(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID}); err != nil {
return approved, nil
}
// mail_health's own overdue check (Overdue, issue 1199) needs to find every send the sender now owns,
// the same way the sweep needs the undecided index: a reserved key, since neither side's own state over
// mesh-sdk go/process lists its keys whole. Indexed BEFORE the record carries "queued" (novox/hq review
// round 1 finding 3): a crash between this and the write below must still leave it findable once
// "queued" does land — Overdue itself only ever matches "queued" or "retrying", so an entry that still
// reads "approved" in the meantime is simply not yet counted overdue.
indexErr := addSenderPending(store, req.ID)
queued := SendDecision{At: now, Outcome: "queued", Warrant: approved.Warrant}
final, newRev, err := updateDecision(store, req, rev, queued)
if err != nil {
// The publish stands either way: the bus already took the message once. The caller logs this; it is
// not retried (a retry here would publish a second message under a new id).
return approved, errOr(indexErr, fmt.Errorf("the send %s was queued, and that could not be kept (%w)", req.ID, err))
}
if newRev != 0 {
// Only once "queued" is durably recorded is the undecided index's entry dropped: removing it first,
// then crashing before the write, would have left the send unreachable by the sweep too.
if rmErr := removeUndecided(store, req.ID); rmErr != nil {
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the undecided index: %w", req.ID, rmErr))
}
}
return final, indexErr
// Best-effort: its own failure is never returned to the caller as this call's own error — the record is
// already durably "queued" either way, and the sweep retries a publish that did not land.
_ = publish(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID})
return claimed, indexErr
}
// ---- the sender's own claim (novox/hq ADR 0436 clauses 9, 10, issue 1196) --------------------------------------
@@ -744,17 +726,8 @@ const (
// message, racing this one); nothing is recorded here and nothing sent; the caller leaves the message to
// the bus entirely — no Ack, no Nak, no Term.
claimLostRace
// claimNotYetQueued: publishQueued (above) publishes to mail-sends before it records queued, so a
// message delivered in that narrow window finds the record still "approved" — the tools really did
// approve it and are about to record queued, so this is not a refusal: nothing is recorded and nobody
// is told; the caller asks the bus to redeliver shortly (novox/hq review round 1 finding 2).
claimNotYetQueued
)
// NotYetQueuedDelay is how long the sender asks the bus to wait before redelivering a message whose record
// is still "approved" (claimNotYetQueued): long enough for the tools' own second write (queued) to land.
const NotYetQueuedDelay = 5 * time.Second
// claimQueuedSend verifies the operator's warrant for id (mesh-mail's own ask) and claims the send by
// compare-and-set from queued or retrying to sending, with the attempt and time, before any connection
// (novox/hq ADR 0436 clauses 9, 10). It is never trusted from the queue's message alone: the ask is read
@@ -772,8 +745,9 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
return SendRequest{}, SendDecision{}, 0, claimLostRace, fmt.Errorf("no send %q is kept", id)
}
if req.Decided == nil {
// Nothing was ever approved: no tell owed (clause 11 ties the tell to a *failed approved* send),
// and nothing emitted either — clause 11's two events are for a send the operator did approve.
// Nothing was ever decided at all (never queued): no tell owed (clause 11 ties the tell to a
// *failed approved* send), and nothing emitted either — clause 11's two events are for a send the
// operator did approve.
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: the send is not yet approved"})
return req, d, 0, claimRefused, err
}
@@ -810,10 +784,6 @@ func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string,
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
}
return req, d, 0, claimNotProven, indexErr
case req.Decided.Outcome == "approved":
// Not yet visible as queued (publishQueued publishes before it records queued): the tools did
// approve it, so this is not a refusal (novox/hq review round 1 finding 2).
return req, *req.Decided, 0, claimNotYetQueued, nil
case req.Decided.Outcome != "queued" && req.Decided.Outcome != "retrying":
return refuse(fmt.Sprintf("the send is %q, neither queued nor retrying", req.Decided.Outcome))
}
@@ -1366,9 +1336,10 @@ func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id string
func reportDecision(out map[string]any, d SendDecision, now time.Time) {
out["at"] = d.At.Format(time.RFC3339)
switch {
case d.Outcome == "approved", d.Outcome == "queued":
// Claimed, and either not yet published to mail-sends (the sweep will try it again) or already
// queued for the sender: both read the same to the operator (novox/hq ADR 0436 clause 8).
case d.Outcome == "queued":
// Recorded queued by the one compare-and-set the Approve makes (novox/hq ADR 0436 clause 8): there
// is no separate "approved" state in a send's life. Published to mail-sends already, or the sweep
// will publish it again once a short grace has passed.
out["status"], out["words"] = "waiting", "approved and waiting for the sender; ask again shortly"
case d.Outcome == "sent":
out["status"], out["words"] = "sent", "sent on the operator's Approve ("+d.Warrant+")"
@@ -1412,39 +1383,55 @@ func (b *Bundle) Heard(w asks.Warrant) {
}
}
// Sweep reads only the sends the undecided index still names. A send not yet decided is asked about the same
// way mail_send_status does (ReadSendStatus): an Approve recorded while this module did not hear it directly
// (down, or not yet subscribed) is still acted on, once. A send left approved (its publish to mail-sends
// failed, or the tools stopped between the two compare-and-set writes) is published again, the send id still
// its message id (novox/hq ADR 0436 clause 8). Unlike reading every send ever kept, its work grows only with
// the sends still undecided, never with every one a module has ever sent (novox/hq ADR 0361 §2, review round 1
// of issue 790; the index, issue 811).
// QueuedGrace is how long a newly queued send is given to be published before the sweep considers it
// possibly stuck and publishes it again: long enough that the tools' own publish, right after the same
// compare-and-set write, is never raced by the sweep under ordinary load (novox/hq ADR 0436 clause 8, as
// decided to replace the two-state approved/queued design, review round 1).
const QueuedGrace = time.Minute
// Sweep does two things, neither reading every send ever kept (its work grows only with the few still
// live, never with history): a send the undecided index still names and that is not yet decided at all is
// asked about the same way mail_send_status does (ReadSendStatus) — an Approve recorded while this module
// did not hear it directly (down, or not yet subscribed) is still acted on, once (novox/hq ADR 0361 §2,
// review round 1 of issue 790; the index, issue 811); and a send the sender-pending index names that is
// still "queued" past QueuedGrace — its publish to mail-sends may have failed, or the tools stopped before
// it ever ran — is published again, the send id still its message id, so the duplicate window and the
// sender's own claim keep it one send either way (novox/hq ADR 0436 clause 8, review round 1).
func (b *Bundle) Sweep(now time.Time) {
store := b.sends()
ids, err := undecidedIDs(store)
undecided, err := undecidedIDs(store)
if err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] the undecided index cannot be read: %v", err)
}
return
}
for _, id := range ids {
req, rev, ok, err := getSend(store, id)
if err != nil || !ok {
continue
}
switch {
case req.Decided == nil:
} else {
for _, id := range undecided {
req, _, 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 {
if b.Logf != nil {
b.Logf("[mesh-mail] sweeping the send %s: %v", id, err)
}
}
case req.Decided.Outcome == "approved":
if _, err := publishQueued(store, req, rev, *req.Decided, b.queuePublish(), now); err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] sweeping the approved send %s: %v", id, err)
}
}
}
pending, err := senderPendingIDs(store)
if err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] the sender-pending index cannot be read: %v", err)
}
return
}
for _, id := range pending {
req, _, ok, err := getSend(store, id)
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" || now.Sub(req.Decided.At) < QueuedGrace {
continue
}
if err := b.queuePublish()(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID}); err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] republishing the queued send %s: %v", id, err)
}
}
}
+26 -20
View File
@@ -566,16 +566,19 @@ func TestSweepActsOnAnApprovedSendItWasNotToldAboutDirectly(t *testing.T) {
if n := queue.count(); n != 1 {
t.Fatalf("the sweep did not act on the approved send: %d message(s)", n)
}
b.Sweep(judgedAt.Add(3 * time.Minute))
// A second sweep, still within the just-queued record's own grace, does not publish it again (past the
// grace, it would: TestSweepPublishesAgainAQueuedRecordPastItsGrace, novox/hq review round 1).
b.Sweep(judgedAt.Add(2*time.Minute + QueuedGrace/2))
if n := queue.count(); n != 1 {
t.Fatalf("a second sweep queued again: %d message(s)", n)
t.Fatalf("a second sweep within the grace queued again: %d message(s)", n)
}
}
// 1194/AC2: A send left "approved" (the publish to mail-sends failed, or the tools stopped between the two
// compare-and-set writes) is still found by the undecided index, and the sweep publishes it again, still under
// the send's own id (novox/hq ADR 0436 clause 8).
func TestSweepPublishesAgainASendLeftApproved(t *testing.T) {
// 1194/AC2 (as decided to replace the two-state approved/queued design, review round 1): the sweep
// republishes a queued, unclaimed record whose queued time is older than QueuedGrace, the send id still
// its message id — the stream's duplicate window and the sender's own claim keep it one send either way
// (proved end to end over a real bus by the module-whole acceptance test, issue 1198 AC4).
func TestSweepPublishesAgainAQueuedRecordPastItsGrace(t *testing.T) {
b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
queue := newMemQueue()
queue.failErr = errors.New("the bus is down")
@@ -592,31 +595,34 @@ func TestSweepPublishesAgainASendLeftApproved(t *testing.T) {
w := router.chose(id, OptionSend, judgedAt.Add(time.Minute))
router.close(w)
if _, err := Decide(sends, b.queuePublish(), w, judgedAt.Add(2*time.Minute)); err != nil {
decidedAt := judgedAt.Add(2 * time.Minute)
if _, err := Decide(sends, b.queuePublish(), w, decidedAt); err != nil {
t.Fatalf("deciding: %v", err)
}
if n := queue.count(); n != 0 {
t.Fatalf("the queue received %d message(s) despite failing", n)
}
// There is no "approved" state: the record is already "queued", its own publish just failed.
req, _, ok, err := getSend(sends, id)
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "approved" {
t.Fatalf("left approved: %v %v %+v", err, ok, req.Decided)
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" {
t.Fatalf("left queued despite the publish failing: %v %v %+v", err, ok, req.Decided)
}
// Once the bus comes back, the sweep publishes the approved send again, and it reaches queued.
queue.failErr = nil
b.Sweep(judgedAt.Add(3 * time.Minute))
if n := queue.count(); n != 1 {
t.Fatalf("the sweep did not publish the approved send: %d message(s)", n)
// Within its grace, the sweep leaves a freshly queued record alone.
b.Sweep(decidedAt.Add(QueuedGrace / 2))
if n := queue.count(); n != 0 {
t.Fatalf("the sweep republished within the grace: %d message(s)", n)
}
req, _, ok, err = getSend(sends, id)
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" {
t.Fatalf("not queued after the sweep: %v %v %+v", err, ok, req.Decided)
}
// A further sweep does not publish a send that already reached queued (it left the undecided index).
b.Sweep(judgedAt.Add(4 * time.Minute))
// Past its grace, the sweep publishes the still-queued send again.
b.Sweep(decidedAt.Add(QueuedGrace + time.Second))
if n := queue.count(); n != 1 {
t.Fatalf("a further sweep published again: %d message(s)", n)
t.Fatalf("the sweep did not republish the queued send past its grace: %d message(s)", n)
}
got := queue.last()
if got.id != id || got.body.Send != id {
t.Fatalf("republished %+v, want the send id as the message id", got)
}
}
@@ -228,13 +228,6 @@ func (h *senderHandler) handle(msg *process.Message) {
switch outcome {
case claimLostRace:
return // another delivery of the same message already owns it.
case claimNotYetQueued:
// The tools' own second write (queued) has not landed yet: not a refusal (novox/hq review round 1
// finding 2). Nothing recorded, nobody told; ask the bus to redeliver shortly.
if err := msg.NakWithDelay(NotYetQueuedDelay); err != nil {
h.logf("[mesh-mail] send: asking %s redelivered: %v", q.Send, err)
}
return
case claimRefused, claimNotProven:
if err := msg.Term(); err != nil {
h.logf("[mesh-mail] send: terminating %s: %v", q.Send, err)
+26 -16
View File
@@ -225,29 +225,39 @@ func TestTellWaitsTellsOnceThroughItsOwnField(t *testing.T) {
}
// 1199/AC4: given each state, when mail_send_status reads it (reportDecision), then it says: waiting for
// the operator, approved and waiting for the sender, being sent, waiting for its next attempt, sent,
// failed with its reason, refused, not proven, declined, or expired.
// the operator (covered by send_test.go's own asking tests, which read ReadSendStatus before any decision
// exists — reportDecision itself only ever sees an already-decided SendDecision), approved and waiting for
// the sender, being sent, waiting for its next attempt, sent, failed with its reason, refused, not proven,
// declined, or expired — checking both the status word and the words that actually say which of these it
// is (novox/hq review round 1 finding 6: checking status alone proves too little).
func TestReportDecisionCoversTheWholeStatusVocabulary(t *testing.T) {
for name, c := range map[string]struct {
d SendDecision
want string
d SendDecision
wantStatus string
wantInWords string
}{
"approved": {SendDecision{At: judgedAt, Outcome: "approved"}, "waiting"},
"queued": {SendDecision{At: judgedAt, Outcome: "queued"}, "waiting"},
"being sent": {SendDecision{At: judgedAt, Outcome: "sending"}, "waiting"},
"retrying": {SendDecision{At: judgedAt, Outcome: "retrying", NextAttempt: judgedAt.Add(time.Minute)}, "waiting"},
"sent": {SendDecision{At: judgedAt, Outcome: "sent"}, "sent"},
"failed with its reason": {SendDecision{At: judgedAt, Outcome: "failed: 550 no such mailbox"}, "failed"},
"refused": {SendDecision{At: judgedAt, Outcome: "refused: the warrant does not bind the request"}, "refused"},
"not proven": {SendDecision{At: judgedAt, Outcome: "not proven"}, "not proven"},
"declined": {SendDecision{At: judgedAt, Outcome: "declined"}, "declined"},
"expired (never heard)": {SendDecision{At: judgedAt, Outcome: "ended: never heard"}, "expired"},
// There is no "approved" state in a send's life: Decide's one compare-and-set write moves it
// straight from undecided to "queued" (novox/hq ADR 0436 clause 8, as decided to replace the
// two-state design, review round 1).
"queued": {SendDecision{At: judgedAt, Outcome: "queued"}, "waiting", "approved and waiting for the sender"},
"being sent": {SendDecision{At: judgedAt, Outcome: "sending"}, "waiting", "being sent"},
"retrying": {SendDecision{At: judgedAt, Outcome: "retrying", NextAttempt: judgedAt.Add(time.Minute)}, "waiting", "waiting for its next attempt"},
"sent": {SendDecision{At: judgedAt, Outcome: "sent"}, "sent", "sent on the operator's Approve"},
"failed with its reason": {SendDecision{At: judgedAt, Outcome: "failed: 550 no such mailbox"}, "failed", "550 no such mailbox"},
"refused": {SendDecision{At: judgedAt, Outcome: "refused: the warrant does not bind the request"}, "refused", "the warrant does not bind the request"},
"not proven": {SendDecision{At: judgedAt, Outcome: "not proven"}, "not proven", "not proven"},
"declined": {SendDecision{At: judgedAt, Outcome: "declined"}, "declined", "the operator declined"},
"expired (never heard)": {SendDecision{At: judgedAt, Outcome: "ended: never heard"}, "expired", "never heard"},
} {
t.Run(name, func(t *testing.T) {
out := map[string]any{}
reportDecision(out, c.d, judgedAt.Add(time.Minute))
if out["status"] != c.want {
t.Fatalf("status: %v, want %q (words: %v)", out["status"], c.want, out["words"])
if out["status"] != c.wantStatus {
t.Fatalf("status: %v, want %q (words: %v)", out["status"], c.wantStatus, out["words"])
}
words, _ := out["words"].(string)
if !strings.Contains(words, c.wantInWords) {
t.Fatalf("words: %q, want it to contain %q", words, c.wantInWords)
}
})
}