Lay out the assigner's ask value and name the asking a view answer is for, so a stale answer never fills a later asking of the same key (issue 1297 subtask B1c, for the view lane's B9 and B10)

This commit is contained in:
2026-10-23 15:20:58 +02:00
parent d9384bcb23
commit f625d98713
2 changed files with 154 additions and 7 deletions
+81 -4
View File
@@ -4,6 +4,8 @@ import (
"encoding/json"
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The
@@ -143,22 +145,97 @@ func ParseViewAnswerKey(key string) (askKey string, err error) {
return askKey, nil
}
// ViewAnswer is the view's answer to an ask: the option the operator chose and their words. The entry's own time
// says when.
// AssignerAsk is the value of a key in AssignerAsksBucket: the router's ask whole, as the assigner composes it, so the
// teller publishes it unchanged. Asking counts the times the assigner wrote the ask at this key, as the gate's Ask
// does: 0 or 1 for the first, n for the n-th, and the ask's id is AskingID(key, Asking), so each asking has its own id
// and an answer to an earlier asking is never taken for a later one.
type AssignerAsk struct {
V int `json:"v"`
Ask asks.Ask `json:"ask"`
Asking int `json:"asking,omitempty"`
}
func (a AssignerAsk) check(key string) error {
ask, err := ParseAnyAskKey(key)
if err != nil {
return err
}
if proof, _ := AskNeedsProof(ask.Kind); proof {
return fmt.Errorf("delivery: the assigner asks no %s ask: it needs proof of who answered", ask.Kind)
}
if a.Asking < 0 {
return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking)
}
if want := AskingID(key, a.Asking); a.Ask.ID != want {
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its key and asking %d give", a.Ask.ID, want, a.Asking)
}
if len(a.Ask.Options) == 0 {
return fmt.Errorf("delivery: the ask has no option")
}
return nil
}
// EncodeAssignerAsk is a, the ask at key, as JSON at Version; it refuses a key of a kind that needs proof or of no
// kind, an id other than the one the key and the asking give, and an ask with no option.
func EncodeAssignerAsk(key string, a AssignerAsk) ([]byte, error) {
if err := a.check(key); err != nil {
return nil, err
}
a.V = Version
return json.Marshal(a)
}
// DecodeAssignerAsk reads the ask at key; it refuses an unknown version (ErrUnknownVersion) and what EncodeAssignerAsk
// refuses.
func DecodeAssignerAsk(key string, data []byte) (AssignerAsk, error) {
var a AssignerAsk
if err := decode(data, &a); err != nil {
return AssignerAsk{}, err
}
if err := a.check(key); err != nil {
return AssignerAsk{}, err
}
return a, nil
}
// SameAssignerAsk says whether two asks are the same value.
func SameAssignerAsk(a, b AssignerAsk) bool {
return a.V == b.V && a.Asking == b.Asking && a.Ask.Digest() == b.Ask.Digest()
}
// ViewAnswer is the view's answer to an ask: the id of the asking it answers (the ask's id, AskingID of its key and
// asking), the option the operator chose and their words. The entry's own time says when.
type ViewAnswer struct {
V int `json:"v"`
Asking string `json:"asking"`
Option string `json:"option"`
Words string `json:"words,omitempty"`
}
func (a ViewAnswer) check() error {
if strings.TrimSpace(a.Asking) == "" {
return fmt.Errorf("delivery: a view answer names no asking")
}
if strings.TrimSpace(a.Option) == "" {
return fmt.Errorf("delivery: a view answer names no option")
}
return nil
}
// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no option.
// ViewAnswerFits says whether the view's answer a answers the ask as it stands now: it refuses an answer to another
// asking of the ask (a stale answer to an earlier writing of the same key) and an option the ask does not offer,
// naming both.
func ViewAnswerFits(a ViewAnswer, ask AssignerAsk) error {
if a.Asking != ask.Ask.ID {
return fmt.Errorf("delivery: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, ask.Ask.ID)
}
if _, ok := ask.Ask.Option(a.Option); !ok {
return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID)
}
return nil
}
// EncodeViewAnswer is a as JSON at Version; it refuses an answer naming no asking or no option.
func EncodeViewAnswer(a ViewAnswer) ([]byte, error) {
if err := a.check(); err != nil {
return nil, err
@@ -168,7 +245,7 @@ func EncodeViewAnswer(a ViewAnswer) ([]byte, error) {
}
// DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no
// option.
// asking or no option.
func DecodeViewAnswer(data []byte) (ViewAnswer, error) {
var a ViewAnswer
if err := decode(data, &a); err != nil {
+73 -3
View File
@@ -3,6 +3,9 @@ package delivery
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
)
// 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an
@@ -73,9 +76,9 @@ func TestViewAnswerKey(t *testing.T) {
}
}
// 1297/B1c: a view answer round-trips; one naming no option is refused.
// 1297/B1c: a view answer round-trips; one naming no asking or no option is refused.
func TestViewAnswerRoundTrip(t *testing.T) {
a := ViewAnswer{Option: "release", Words: "fixed by #12"}
a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"}
data, err := EncodeViewAnswer(a)
if err != nil {
t.Fatal(err)
@@ -85,7 +88,74 @@ func TestViewAnswerRoundTrip(t *testing.T) {
if err != nil || !SameViewAnswer(got, a) {
t.Errorf("view answer came back %+v, %v", got, err)
}
if _, err := EncodeViewAnswer(ViewAnswer{Option: " "}); err == nil {
if _, err := EncodeViewAnswer(ViewAnswer{Asking: "x", Option: " "}); err == nil {
t.Errorf("a view answer naming no option was accepted")
}
if _, err := EncodeViewAnswer(ViewAnswer{Option: "release"}); err == nil {
t.Errorf("a view answer naming no asking was accepted")
}
}
func holdAsk(t *testing.T, asking int) (string, AssignerAsk) {
t.Helper()
key, err := HoldAskKey("mesh-mail", fp2of('f'))
if err != nil {
t.Fatal(err)
}
return key, AssignerAsk{Asking: asking, Ask: asks.Ask{ID: AskingID(key, asking), Headline: "mesh-mail is held", Explanation: "its health failed",
Options: []asks.Option{{ID: "release", Label: "Release"}, {ID: "keep", Label: "Keep held"}}, Who: "operator",
Expires: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC), OnExpiry: "it stays held"}}
}
// 1297/B1c: the assigner's ask round-trips at its key; an id another key or asking gives, an ask of a kind that needs
// proof and one with no option are refused.
func TestAssignerAskRoundTrip(t *testing.T) {
key, a := holdAsk(t, 2)
data, err := EncodeAssignerAsk(key, a)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(data), `{"v":1,"ask":`) {
t.Errorf("the ask is written as %s", data)
}
got, err := DecodeAssignerAsk(key, data)
a.V = Version
if err != nil || !SameAssignerAsk(got, a) {
t.Errorf("the ask came back %+v, %v", got, err)
}
other, _ := HoldAskKey("mesh-web", fp2of('f'))
if _, err := DecodeAssignerAsk(other, data); err == nil {
t.Errorf("an ask was read at another key")
}
stale := a
stale.Asking = 3
if _, err := EncodeAssignerAsk(key, stale); err == nil {
t.Errorf("an ask whose id is another asking's was accepted")
}
merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}})
if _, err := EncodeAssignerAsk(merge, AssignerAsk{Ask: asks.Ask{ID: AskingID(merge, 0), Options: a.Ask.Options}}); err == nil {
t.Errorf("the assigner was let write a merge's ask")
}
none := a
none.Ask.Options = nil
if _, err := EncodeAssignerAsk(key, none); err == nil {
t.Errorf("an ask with no option was accepted")
}
}
// 1297/B1c: a view answer fits only the asking it names: an answer to an earlier asking of the same key is refused,
// naming both, and so is an option the ask does not offer.
func TestViewAnswerFits(t *testing.T) {
key, first := holdAsk(t, 0)
_, second := holdAsk(t, 2)
answer := ViewAnswer{Asking: AskingID(key, 0), Option: "release"}
if err := ViewAnswerFits(answer, first); err != nil {
t.Errorf("the answer to the first asking does not fit it: %v", err)
}
if err := ViewAnswerFits(answer, second); err == nil || !strings.Contains(err.Error(), first.Ask.ID) || !strings.Contains(err.Error(), second.Ask.ID) {
t.Errorf("a stale answer fits the later asking, or the refusal names neither: %v", err)
}
if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil {
t.Errorf("an option the ask does not offer fits")
}
}