From d9384bcb239a84502d1495c24c7227b67f579e49 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 23 Oct 2026 15:12:40 +0200 Subject: [PATCH] Lay out the asks the view may answer and the view's answer key, refusing the merge's ask, so the view and the teller answer only what needs no proof (issue 1297 subtask B1c; ADR 0528 clause 6, ADR 0437 clause 10) --- go/delivery/view_answers.go | 184 +++++++++++++++++++++++++++++++ go/delivery/view_answers_test.go | 91 +++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 go/delivery/view_answers.go create mode 100644 go/delivery/view_answers_test.go diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go new file mode 100644 index 0000000..e7524a9 --- /dev/null +++ b/go/delivery/view_answers.go @@ -0,0 +1,184 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "strings" +) + +// The asks the view may answer, and its answers (novox/hq ADR 0528 clause 6, ADR 0429 §3 and §6, ADR 0437 §10). The +// gate's merge ask (`merge..`, MergeAskKey) needs proof of who answered (ADR 0421 §1), so it is +// answered on the phone alone until issue 953 closes. The assigner's asks need no such proof, and the view may +// answer them: the hold of a fingerprint whose health check failed (`hold..`: release it or +// keep it held) and the restore of a data module set back (`restore...`: restore and +// lose newer writes, or leave it stopped, ADR 0429 §6). The assigner writes them in its bucket asks; the view writes +// its answer `.view` in its bucket answers, and the teller, which alone fills an ask's answer, takes it from +// there as it takes a warrant from the phone. +const ( + // ViewModule is the module of the view part. + ViewModule = "mesh-delivery-view" + // ViewAnswersBucket is the view's bucket of answers, the only state the view writes. + ViewAnswersBucket = ViewModule + "_answers" + // ViewAnswersHistory is the revisions the bucket keeps per key. + ViewAnswersHistory = 5 + // AssignerAsksBucket is the assigner's bucket of asks that need no proof of who answered. + AssignerAsksBucket = AssignerModule + "_asks" + // AssignerAsksHistory is the revisions the bucket keeps per key. + AssignerAsksHistory = 5 + + // AskKindHold is the kind of the assigner's ask about a held fingerprint. + AskKindHold = "hold" + // AskKindRestore is the kind of the assigner's ask about a data module set back. + AskKindRestore = "restore" + + viewWord = "view" +) + +// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's +// Module and fingerprint FP, a restore's Node, Module and FP. +type AskKey struct { + Kind string + Group string + Hash string + Node string + Module string + FP string +} + +// AskNeedsProof says whether an ask of this kind must prove who answered it, so the view may not answer it: the +// merge's Approve does (ADR 0421 §1, issue 953), the assigner's hold and restore do not. An unknown kind is refused. +func AskNeedsProof(kind string) (bool, error) { + switch kind { + case AskKindMerge: + return true, nil + case AskKindHold, AskKindRestore: + return false, nil + } + return false, fmt.Errorf("delivery: %q is not a kind of ask, %s, %s or %s", kind, AskKindMerge, AskKindHold, AskKindRestore) +} + +// HoldAskKey is the key, in AssignerAsksBucket, of the ask about a module's held fingerprint: `hold..`. +func HoldAskKey(module, fingerprint string) (string, error) { + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindHold + "." + key, nil +} + +// RestoreAskKey is the key, in AssignerAsksBucket, of the ask about a data module set back on a node from a +// fingerprint: `restore...`. +func RestoreAskKey(node, module, fingerprint string) (string, error) { + if err := checkName("node", node); err != nil { + return "", err + } + key, err := BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindRestore + "." + node + "." + key, nil +} + +// ParseAnyAskKey reads an ask key of any kind (MergeAskKey, HoldAskKey, RestoreAskKey); it refuses an unknown kind +// and a key whose parts do not fit its kind, naming the key. +func ParseAnyAskKey(key string) (AskKey, error) { + kind, rest, _ := strings.Cut(key, ".") + refused := func(err error) (AskKey, error) { + return AskKey{}, fmt.Errorf("delivery: %q is not an ask key: %w", key, err) + } + switch kind { + case AskKindMerge: + _, group, hash, err := ParseAskKey(key) + if err != nil { + return AskKey{}, err + } + return AskKey{Kind: kind, Group: group, Hash: hash}, nil + case AskKindHold: + module, fp, err := ParseBuilderBuildKey(rest) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Module: module, FP: fp}, nil + case AskKindRestore: + node, build, ok := strings.Cut(rest, ".") + if !ok { + return refused(fmt.Errorf("a restore ask is restore...")) + } + if err := checkName("node", node); err != nil { + return refused(err) + } + module, fp, err := ParseBuilderBuildKey(build) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + } + _, err := AskNeedsProof(kind) + return refused(err) +} + +// ViewAnswerKey is the key, in ViewAnswersBucket, of the view's answer to the ask at askKey: `.view`. An ask +// that needs proof of who answered is refused with its reason: it is answered on the phone (issue 953). +func ViewAnswerKey(askKey string) (string, error) { + ask, err := ParseAnyAskKey(askKey) + if err != nil { + return "", err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return "", fmt.Errorf("delivery: the ask %s needs proof of who answered: answer it on the phone (issue 953)", askKey) + } + return askKey + "." + viewWord, nil +} + +// ParseViewAnswerKey is the inverse of ViewAnswerKey: the ask's key. It refuses a key without `.view`, an ask key of +// a kind that needs proof, and a key of another bucket, naming it. +func ParseViewAnswerKey(key string) (askKey string, err error) { + askKey, ok := strings.CutSuffix(key, "."+viewWord) + if !ok { + return "", fmt.Errorf("delivery: %q is not a view answer key, .view", key) + } + if _, err := ViewAnswerKey(askKey); err != nil { + return "", fmt.Errorf("delivery: %q is not a view answer key: %w", key, err) + } + 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. +type ViewAnswer struct { + V int `json:"v"` + Option string `json:"option"` + Words string `json:"words,omitempty"` +} + +func (a ViewAnswer) check() error { + 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. +func EncodeViewAnswer(a ViewAnswer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeViewAnswer reads a view answer; it refuses an unknown version (ErrUnknownVersion) and an answer naming no +// option. +func DecodeViewAnswer(data []byte) (ViewAnswer, error) { + var a ViewAnswer + if err := decode(data, &a); err != nil { + return ViewAnswer{}, err + } + if err := a.check(); err != nil { + return ViewAnswer{}, err + } + return a, nil +} + +// SameViewAnswer says whether two view answers are the same value. +func SameViewAnswer(a, b ViewAnswer) bool { return a == b } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go new file mode 100644 index 0000000..d3bca5d --- /dev/null +++ b/go/delivery/view_answers_test.go @@ -0,0 +1,91 @@ +package delivery + +import ( + "strings" + "testing" +) + +// 1297/B1c: the kinds of ask, and which need proof of who answered: the merge does, hold and restore do not, an +// unknown kind is refused. +func TestAskNeedsProof(t *testing.T) { + for kind, want := range map[string]bool{AskKindMerge: true, AskKindHold: false, AskKindRestore: false} { + if got, err := AskNeedsProof(kind); err != nil || got != want { + t.Errorf("AskNeedsProof(%q) = %v, %v; want %v", kind, got, err, want) + } + } + if _, err := AskNeedsProof("approve"); err == nil { + t.Errorf("an unknown kind was read") + } +} + +// 1297/B1c: each kind of ask key round-trips through ParseAnyAskKey; a key that fits no kind is refused, naming it. +func TestParseAnyAskKey(t *testing.T) { + fp := fp2of('d') + merge, _ := MergeAskKey("feat/r4g", []Head{{Repository: "novox/mesh-sdk", Number: 1, Head: strings.Repeat("1", 40)}}) + hold, err := HoldAskKey("mesh-mail", fp) + if err != nil || hold != "hold.mesh-mail."+strings.Repeat("d", 64) { + t.Fatalf("HoldAskKey = %q, %v", hold, err) + } + restore, err := RestoreAskKey("ace", "mesh-mail", fp) + if err != nil || restore != "restore.ace.mesh-mail."+strings.Repeat("d", 64) { + t.Fatalf("RestoreAskKey = %q, %v", restore, err) + } + for key, want := range map[string]AskKey{ + hold: {Kind: AskKindHold, Module: "mesh-mail", FP: fp}, + restore: {Kind: AskKindRestore, Node: "ace", Module: "mesh-mail", FP: fp}, + } { + if got, err := ParseAnyAskKey(key); err != nil || got != want { + t.Errorf("ParseAnyAskKey(%q) = %+v, %v", key, got, err) + } + } + if got, err := ParseAnyAskKey(merge); err != nil || got.Kind != AskKindMerge || got.Group != "feat/r4g" || len(got.Hash) != 12 { + t.Errorf("ParseAnyAskKey(%q) = %+v, %v", merge, got, err) + } + for _, bad := range []string{"want.ace.mesh-mail", "hold.mesh-mail", "restore.mesh-mail." + strings.Repeat("d", 64), "approve.x.y", "restore.a.b.c.d", ""} { + if _, err := ParseAnyAskKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseAnyAskKey(%q) = %v, want a refusal naming it", bad, err) + } + } + if _, err := RestoreAskKey("a.b", "m", fp); err == nil { + t.Errorf("a restore ask on a dotted node was keyed") + } +} + +// 1297/B1c: the view's answer key is the ask's key and `.view`, and round-trips; the merge's ask is refused with its +// reason (issue 953); another bucket's key is refused, naming it. +func TestViewAnswerKey(t *testing.T) { + hold, _ := HoldAskKey("mesh-mail", fp2of('e')) + key, err := ViewAnswerKey(hold) + if err != nil || key != hold+".view" { + t.Fatalf("ViewAnswerKey = %q, %v", key, err) + } + if back, err := ParseViewAnswerKey(key); err != nil || back != hold { + t.Errorf("ParseViewAnswerKey = %q, %v", back, err) + } + merge, _ := MergeAskKey("g", []Head{{Repository: "novox/hq", Number: 2, Head: strings.Repeat("2", 40)}}) + if _, err := ViewAnswerKey(merge); err == nil || !strings.Contains(err.Error(), "phone") || !strings.Contains(err.Error(), "953") { + t.Errorf("the merge's ask was answerable in the view, or its reason is missing: %v", err) + } + for _, bad := range []string{hold, merge + ".view", "want.ace.mesh-mail.view", "ace.mesh-mail", ""} { + if _, err := ParseViewAnswerKey(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") { + t.Errorf("ParseViewAnswerKey(%q) = %v, want a refusal naming it", bad, err) + } + } +} + +// 1297/B1c: a view answer round-trips; one naming no option is refused. +func TestViewAnswerRoundTrip(t *testing.T) { + a := ViewAnswer{Option: "release", Words: "fixed by #12"} + data, err := EncodeViewAnswer(a) + if err != nil { + t.Fatal(err) + } + got, err := DecodeViewAnswer(data) + a.V = Version + if err != nil || !SameViewAnswer(got, a) { + t.Errorf("view answer came back %+v, %v", got, err) + } + if _, err := EncodeViewAnswer(ViewAnswer{Option: " "}); err == nil { + t.Errorf("a view answer naming no option was accepted") + } +}