Subtask T: the teller says an assigner ask that ended at the channel without an answer as its event ask-ended (issue 1966)

An assigner ask that lapsed or was cancelled at the operator channel while its key still stands
was never asked again: the router drops a second ask under an id it keeps, and the assigner
only writes a new asking when the key is absent (note 151: the nats yes on novox and three
backup asks). The teller now says ask-ended (its module event, emits; body in its own package
tellerevents, ADR 0634 §1) when the router records such an end, and when a watch hands it a key
standing under an asking that ended so, which covers its start and the asks standing now. A
refusal, a replacement, an answer and an end by hand (teller_end_ask, kept in its own record)
are not said.

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
2026-11-10 01:07:05 +01:00
parent 8b03cc036e
commit b39b0771a0
12 changed files with 288 additions and 29 deletions
@@ -12,6 +12,7 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents"
)
// The assigner's asks reach the operator (issue 1906). The assigner writes its asks (the hold of a fingerprint, the restore
@@ -163,15 +164,21 @@ func (t *Teller) raiseAssignerAsk(key string) {
return
}
if k != nil {
byHand := grec != nil && prior.ID == id && prior.Hand
if grec == nil || prior.ID != id {
_ = t.note(id, key, 0, grec)
}
if open(k) {
switch {
case open(k):
t.catchUp(id)
} else {
// Said, never silent (issue 1966: a record ask taken back when its key went was written again under the same id on
// 2026-11-09 and nothing said why the operator was never asked): the assigner numbers each writing (asks.Ask.Asking).
t.logf("%s: the ask %s ended (answered, lapsed or taken back) and the assigner wrote the key again under the same id: not asked again, the router would drop it; a writing under a new asking is asked", key, id)
case endedUnanswered(k) && !byHand:
// The key stands under an asking that ended at the channel without an answer, heard now or at the teller's start
// (issue 1966: the nats yes on novox and three backup asks stood so after #195, and nothing asked them again): the
// assigner is told, and writes the key again under a new asking where its kind allows it.
t.sayAssignerAskEnded(key, id, k.Ended)
default:
// Said, never silent (issue 1966): an answered asking, or one a person ended by hand, is not asked again.
t.logf("%s: the ask %s ended (%s) and the assigner's key still stands under it: not asked again, the router would drop it; a writing under a new asking is asked", key, id, endedWords(k, byHand))
}
return
}
@@ -238,3 +245,42 @@ func (t *Teller) answerAssigner(key string, o oc.Option, w oc.Warrant) error {
}
return t.writeAnswer(key, w.Ask, body, occ.Says(w))
}
// endedUnanswered says whether an asking ended at the channel without an answer the assigner could ask again for: it lapsed,
// or it was cancelled (at the router, or by the teller when its key was taken away). A refusal would be refused again, and a
// replaced asking has a newer one standing.
func endedUnanswered(k *occ.Kept) bool {
return k != nil && !k.Acted && (k.Ended == occ.OutcomeExpired || k.Ended == occ.OutcomeCancelled)
}
// endedWords says how an ended asking ended, for the journal.
func endedWords(k *occ.Kept, byHand bool) string {
switch {
case byHand:
return "ended by hand"
case k.Acted:
return "answered"
}
return k.Ended
}
// sayAssignerAskEnded says the teller's event ask-ended for the assigner's ask at key whose asking id ended at the channel
// with outcome, once per asking (the bus drops a second message under its id): the assigner writes the key again under a new
// asking (issue 1966). An outcome the assigner could not ask again for is not said.
func (t *Teller) sayAssignerAskEnded(key, id, outcome string) {
if outcome != occ.OutcomeExpired && outcome != occ.OutcomeCancelled {
return
}
body, err := tellerevents.EncodeEnded(tellerevents.Ended{Key: key, Ask: id, Outcome: outcome})
if err == nil && t.Emit == nil {
err = fmt.Errorf("the teller says no events here")
}
if err == nil {
err = t.Emit(tellerevents.AskEnded, body, tellerevents.AskEnded+"."+id)
}
if err != nil {
t.logf("%s: the ask %s ended (%s) without an answer, and the assigner could not be told (%v): it is told again at the teller's next start", key, id, outcome, err)
return
}
t.logf("%s: the ask %s ended (%s) without an answer: the assigner is told (%s), and writes the key again under a new asking where its kind allows it", key, id, outcome, tellerevents.AskEnded)
}
@@ -1,9 +1,12 @@
package main
import (
"slices"
"encoding/json"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents"
"strings"
"testing"
@@ -212,46 +215,91 @@ func TestABurstOfHoldsLeavesRoomForAMergeAsk(t *testing.T) {
}
// Issue 1966 (nats on novox): an assigner ask taken back when its key went is asked again when the assigner writes the key
// again under a new asking, whatever its kind; written again under the id the router kept it is not (the router would drop it)
// and the teller says so, never silent.
// again under a new asking, whatever its kind; written again under the id the router kept it is not (the router would drop it).
// An asking that ended at the channel without an answer while its key stands (taken back and written again under its id, or
// lapsed at the router) is said to the assigner as the teller's event ask-ended, so the assigner writes it again under a new
// asking (note 151); one answered, refused, replaced or ended by hand is not.
func anAssignerAskWrittenAgainAfterBeingTakenAwayIsAskedAgainUnderANewAsking(t *testing.T) {
digest := "sha256:" + strings.Repeat("e", 64)
fp := "src2:" + strings.Repeat("a", 64)
recordKey, _ := asks.RecordAskKey("novox", "nats", digest)
restoreKey, _ := asks.RestoreAskKey("novox", "nats", fp)
holdKey, _ := asks.HoldAskKey("nats", fp)
for kind, key := range map[string]string{"record": recordKey, "restore": restoreKey, "hold": holdKey} {
t.Run(kind, func(t *testing.T) {
write := func(assigner memGate, asking int) oc.Ask {
ask := oc.Ask{ID: meshdeliveryclient.AskingID(key, asking), Headline: "nats waits", Explanation: "e", Who: occ.Operator,
Expires: occ.FormatTime(now.Add(occ.ApproveLasts)), OnExpiry: "stays",
Options: []oc.Option{{ID: "yes", Label: "Yes", Does: "d", Level: occ.Acknowledge}, {ID: "no", Label: "No", Does: "d", Level: occ.Acknowledge}}}
value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask), Asking: asking})
if err != nil {
t.Fatal(err)
}
assigner[key] = value
return ask
backupKey, _ := asks.BackupAskKey("novox", "nats", fp)
for kind, key := range map[string]string{"record": recordKey, "restore": restoreKey, "hold": holdKey, "backup": backupKey} {
write := func(t *testing.T, assigner memGate, asking int) oc.Ask {
ask := oc.Ask{ID: meshdeliveryclient.AskingID(key, asking), Headline: "nats waits", Explanation: "e", Who: occ.Operator,
Expires: occ.FormatTime(now.Add(occ.ApproveLasts)), OnExpiry: "stays",
Options: []oc.Option{{ID: "yes", Label: "Yes", Does: "d", Level: occ.Acknowledge}, {ID: "no", Label: "No", Does: "d", Level: occ.Acknowledge}}}
value, err := asks.EncodeAsk(key, asks.Ask{Ask: rawAsk(t, ask), Asking: asking})
if err != nil {
t.Fatal(err)
}
assigner[key] = value
return ask
}
t.Run(kind, func(t *testing.T) {
b := newBed(t)
assigner, _ := withView(b)
first := write(assigner, 1000)
first := write(t, assigner, 1000)
b.t.OnAssignerAsk(key)
delete(assigner, key)
b.t.OnAssignerAskGone(key) // the assigner took it away as stale: the router ask is taken back
second := write(assigner, 1060)
second := write(t, assigner, 1060)
b.t.OnAssignerAsk(key)
if got := b.askedIDs(); len(got) != 2 || got[0] != first.ID || got[1] != second.ID || first.ID == second.ID {
t.Fatalf("asked %v, want %s then %s; log:\n%s", got, first.ID, second.ID, b.logs.String())
}
// Written again under the id the router kept, after it was taken back: not asked, and said.
if len(b.emitted) != 0 {
t.Fatalf("said %v while every asking was asked", b.emitted)
}
// Written again under the id the router kept, after it was taken back: not asked, and the assigner is told.
delete(assigner, key)
b.t.OnAssignerAsk(key) // a watch's replay, as at the teller's start, before the key went
b.t.OnAssignerAskGone(key)
write(assigner, 1060)
write(t, assigner, 1060)
b.t.OnAssignerAsk(key)
if got := b.askedIDs(); len(got) != 2 || !strings.Contains(b.logs.String(), "wrote the key again under the same id") {
t.Errorf("asked %v after the same id was written again; log:\n%s", got, b.logs.String())
want := []string{tellerevents.AskEnded + " " + key + " " + second.ID + " " + occ.OutcomeCancelled}
if got := b.askedIDs(); len(got) != 2 || !slices.Equal(b.emitted, want) {
t.Errorf("asked %v and said %v after the same id was written again, want %v; log:\n%s", got, b.emitted, want, b.logs.String())
}
})
for _, end := range []struct {
name string
end func(b *bed, a oc.Ask)
said string // the outcome said to the assigner, or "" for none
}{
{"lapsed at the router", func(b *bed, a oc.Ask) {
b.deliver(t, oc.Warrant{Ask: a.ID, Asker: tellerModule, Outcome: occ.OutcomeExpired, At: occ.FormatTime(now), AskDigest: occ.Digest(a)})
}, occ.OutcomeExpired},
{"cancelled at the router", func(b *bed, a oc.Ask) {
b.deliver(t, oc.Warrant{Ask: a.ID, Asker: tellerModule, Outcome: occ.OutcomeCancelled, At: occ.FormatTime(now), AskDigest: occ.Digest(a)})
}, occ.OutcomeCancelled},
{"replaced at the router", func(b *bed, a oc.Ask) {
b.deliver(t, oc.Warrant{Ask: a.ID, Asker: tellerModule, Outcome: occ.OutcomeReplaced, At: occ.FormatTime(now), AskDigest: occ.Digest(a)})
}, ""},
{"refused for its words", func(b *bed, a oc.Ask) { b.refuse(t, a.ID, "the ask has no headline") }, ""},
{"answered", func(b *bed, a oc.Ask) { b.deliver(t, ackFor(a, "yes")) }, ""},
{"ended by hand", func(b *bed, a oc.Ask) { b.t.EndAsk(a.ID, "the operator asked", "lead@shanks") }, ""},
} {
t.Run(kind+"/"+end.name, func(t *testing.T) {
b := newBed(t)
assigner, _ := withView(b)
a := write(t, assigner, 1000)
b.t.OnAssignerAsk(key)
end.end(b, a)
b.t.OnAssignerAsk(key) // the key still stands under that asking, as at the teller's start
var want []string
if end.said != "" {
want = []string{tellerevents.AskEnded + " " + key + " " + a.ID + " " + end.said}
}
if !slices.Equal(b.emitted, want) {
t.Errorf("said %v, want %v; log:\n%s", b.emitted, want, b.logs.String())
}
if got := b.askedIDs(); len(got) != 1 {
t.Errorf("asked %v, want the one asking only: the assigner writes a new one", got)
}
})
}
}
}
@@ -63,6 +63,7 @@ func (t *Teller) EndAsk(ask, why, by string) ends.Result {
r.Says = "the ask " + id + " could not be cancelled at the router (see the teller's journal): it stays open"
return r
}
t.markHand(key, id)
r.Ended = true
r.Says = "the ask " + id + " is cancelled at the router, which frees its place; the next ask waiting for room is asked when the router says it ended"
t.logf("%s: the ask %s is ended by hand, by %s: %s", key, id, by, why)
@@ -90,3 +91,16 @@ func endAsked(ctx context.Context, t *Teller, kv jetstream.KeyValue, key string,
t.logf("%s.%s: the answer could not be written: %v", ends.State, key, err)
}
}
// markHand keeps, beside the latest asking of key, that a person ended the asking id by hand, so the assigner is not told it
// ended at the channel (issue 1966). A record that names another asking is left.
func (t *Teller) markHand(key, id string) {
g, rec, err := t.seenAt(gateRecordKey(key))
if err != nil || rec == nil || g.ID != id || g.Hand {
return
}
g.Hand = true
if _, err := t.Store.Update(gateRecordKey(key), g, rec.Revision); err != nil {
t.logf("%s: that the ask %s was ended by hand could not be kept: %v", key, id, err)
}
}
@@ -79,7 +79,12 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
client := &occ.Client{Module: tellerModule, State: state, Publish: publish,
Record: recordVia(ctx, conn, tellerModule), Subscribe: decidedVia(hub.Handle, logger.Printf)}
t := &Teller{Asker: client, Store: state, Answers: kvAnswers{ctx: ctx, kv: kvs[0]}, Gate: kvGate{ctx: ctx, kv: gate},
Say: sayVia(ctx, js, tellerModule), Logf: logger.Printf, Publish: publish}
Say: sayVia(ctx, js, tellerModule), Logf: logger.Printf, Publish: publish,
// The teller's own events (its manifest's emits), on EVENTS under its module's subject.
Emit: func(event string, body []byte, id string) error {
_, err := js.Publish(ctx, cred.EventSubject(event), body, jetstream.WithMsgID(id))
return err
}}
// Hear the warrants first, so none that comes while the asks are read is missed.
if err := client.Listen(t.Answer, t.Said); err != nil {
return fmt.Errorf("hearing the router's warrants: %w", err)
@@ -102,6 +102,9 @@ type Teller struct {
Publish func(subject string, body any, id string) error
// Now is the clock; nil is time.Now.
Now func() time.Time
// Emit says one of the teller's own events (its manifest's emits, tellerevents) under a bus message id of its own; nil
// says none, and the journal says so.
Emit func(event string, body []byte, id string) error
mu sync.Mutex
}
@@ -119,6 +122,9 @@ type seen struct {
Key string `json:"key,omitempty"`
ID string `json:"id,omitempty"`
Rev uint64 `json:"rev"`
// Hand marks the asking ID as ended by hand (teller_end_ask): a person's end, so the assigner is never told it ended at
// the channel and its key is not written again for it (issue 1966).
Hand bool `json:"hand,omitempty"`
}
func gateRecordKey(key string) string { return "gate." + key }
@@ -454,6 +460,11 @@ func (t *Teller) said(h occ.Handled, w oc.Warrant, err error) {
return // a second word of the same refusal is no end of an ask
}
t.saidOnce(h, w, err)
if h == occ.Recorded {
if s, rec, _ := t.seenOf(w.Ask); rec != nil && isAssignerKey(s.Key) {
t.sayAssignerAskEnded(s.Key, w.Ask, w.Outcome)
}
}
if h == occ.Acted || h == occ.Recorded || h == occ.Done {
// One of the teller's asks ended at the router, so it has room for one more: the first ask waiting is asked again.
t.endWait(w.Ask)
@@ -7,6 +7,7 @@ import (
"fmt"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents"
"strings"
"testing"
"time"
@@ -132,6 +133,8 @@ type bed struct {
busDropped []string
hear func(stdio.Envelope) error
logs strings.Builder
// emitted are the teller's own events said, as `<event> <key> <ask> <outcome>`.
emitted []string
}
func newBed(t *testing.T) *bed {
@@ -178,7 +181,20 @@ func newBed(t *testing.T) *bed {
b.conds = append(b.conds, c)
return nil
},
Logf: func(f string, a ...any) { fmt.Fprintf(&b.logs, f+"\n", a...) }}
Logf: func(f string, a ...any) { fmt.Fprintf(&b.logs, f+"\n", a...) },
Emit: func(event string, body []byte, id string) error {
e, err := tellerevents.DecodeEnded(body)
if err != nil || id != event+"."+e.Ask {
t.Errorf("the event %s said under %s holds %s: %v", event, id, body, err)
}
if b.msgIDs[id] {
b.busDropped = append(b.busDropped, id) // the bus drops a second message under an id it holds
return nil
}
b.msgIDs[id] = true
b.emitted = append(b.emitted, strings.Join([]string{event, e.Key, e.Ask, e.Outcome}, " "))
return nil
}}
if err := b.client.Listen(b.t.Answer, b.t.Said); err != nil {
t.Fatal(err)
}
+3
View File
@@ -7,6 +7,7 @@ require (
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
@@ -35,6 +36,8 @@ require (
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents => ./tellerevents
require (
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000 // indirect
@@ -3,6 +3,7 @@ package manifest
import (
"bytes"
"encoding/json"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents"
"io/fs"
"os"
"path/filepath"
@@ -42,6 +43,7 @@ type manifest struct {
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Consumes []string `json:"consumes"`
Emits []string `json:"emits"`
OwnSecrets map[string]string `json:"own-secrets"`
SecretsOwner string `json:"secrets-owner"`
Resources []map[string]any `json:"resources"`
@@ -116,8 +118,12 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
if want := []string{"teller_end_ask", "teller_first_views", "teller_first_view_again"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// No emits, streams, bus or seat (ADR 0437 §4).
for _, k := range []string{"streams", "logs", "emits", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} {
// Its one event: an assigner ask ended at the channel without an answer, heard by the assigner (issue 1966, tellerevents).
if want := []string{tellerevents.AskEnded}; !reflect.DeepEqual(m.Emits, want) {
t.Errorf("emits = %v, want %v", m.Emits, want)
}
// No streams, bus or seat (ADR 0437 §4).
for _, k := range []string{"streams", "logs", "claims", "provides", "requires", "defines-seats", "seat", "seats", "bus"} {
if _, ok := top[k]; ok {
t.Errorf("the manifest declares %q: the teller has none", k)
}
+3
View File
@@ -14,6 +14,9 @@
"consumes": [
"mesh-delivery-assigner.views.asks"
],
"emits": [
"ask-ended"
],
"state": [
{ "name": "answers", "history": 5 },
{ "name": "asked", "history": 1 },
@@ -0,0 +1,5 @@
module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
@@ -0,0 +1,59 @@
// Package tellerevents holds the layouts of the teller's own events, which other modules consume (novox/hq ADR 0634 §1: a
// layout one module owns is that module's own package). The teller says them as its module's events,
// `mesh.mod.mesh-delivery-teller.event.<name>`, named in its manifest's `emits`.
package tellerevents
import (
"bytes"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// AskEnded is the event the teller says when one of the assigner's asks ended at the operator channel without an answer
// (lapsed, or cancelled there) while its key still stands under that asking (issue 1966: the nats yes on novox and three
// backup asks were asked once, ended at the channel, and nothing asked them again). The assigner hears it and writes the
// key again under a new asking where the ask's kind allows it.
const AskEnded = "ask-ended"
// Ended is the body of AskEnded: the key of the ask in the assigner's asks, the router id of the asking that ended, and the
// router's outcome (expired or cancelled).
type Ended struct {
V int `json:"v"`
Key string `json:"key"`
Ask string `json:"ask"`
Outcome string `json:"outcome"`
}
func (e Ended) check() error {
for _, f := range []struct{ name, value string }{{"key", e.Key}, {"ask", e.Ask}, {"outcome", e.Outcome}} {
if f.value == "" {
return fmt.Errorf("tellerevents: an ended ask has no %s", f.name)
}
}
return nil
}
// EncodeEnded is e as the body of AskEnded, at keys.Version; it refuses an ended ask lacking its key, its ask or its outcome.
func EncodeEnded(e Ended) ([]byte, error) {
if err := e.check(); err != nil {
return nil, err
}
e.V = keys.Version
return json.Marshal(e)
}
// DecodeEnded reads the body of AskEnded; it refuses an unknown version, an unknown field and what EncodeEnded refuses.
func DecodeEnded(data []byte) (Ended, error) {
if err := keys.CheckVersion(data); err != nil {
return Ended{}, err
}
var e Ended
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&e); err != nil {
return Ended{}, fmt.Errorf("tellerevents: an ended ask does not fit its shape: %w", err)
}
return e, e.check()
}
@@ -0,0 +1,43 @@
package tellerevents
import (
"strings"
"testing"
)
// An ended ask goes through its encoding whole, and one lacking a part, or of another shape, is refused naming it.
func TestEnded(t *testing.T) {
whole := Ended{Key: "record.novox.nats.0de05f93", Ask: "record-novox-nats-x", Outcome: "expired"}
for _, c := range []struct {
name string
e Ended
raw string
want string
}{
{name: "whole", e: whole},
{name: "no key", e: Ended{Ask: "a", Outcome: "expired"}, want: "no key"},
{name: "no ask", e: Ended{Key: "k", Outcome: "expired"}, want: "no ask"},
{name: "no outcome", e: Ended{Key: "k", Ask: "a"}, want: "no outcome"},
{name: "unknown field", raw: `{"v":1,"key":"k","ask":"a","outcome":"expired","more":1}`, want: "shape"},
} {
t.Run(c.name, func(t *testing.T) {
raw := []byte(c.raw)
if c.raw == "" {
var err error
if raw, err = EncodeEnded(c.e); err != nil {
if c.want == "" || !strings.Contains(err.Error(), c.want) {
t.Fatalf("EncodeEnded: %v, want %q", err, c.want)
}
return
}
}
got, err := DecodeEnded(raw)
switch {
case c.want != "" && (err == nil || !strings.Contains(err.Error(), c.want)):
t.Fatalf("DecodeEnded: %v, want %q", err, c.want)
case c.want == "" && (err != nil || got.Key != c.e.Key || got.Ask != c.e.Ask || got.Outcome != c.e.Outcome || got.V != 1):
t.Fatalf("DecodeEnded = %+v, %v; want %+v", got, err, c.e)
}
})
}
}