From c183f170cc60a0cc73ee58ea45dfa24e4d9a1542 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 14:52:26 +0100 Subject: [PATCH] =?UTF-8?q?mesh-delivery-assigner:=20the=20asks=20package?= =?UTF-8?q?=20(the=20keys=20of=20its=20asks=20and=20their=20value)=20is=20?= =?UTF-8?q?the=20module's=20own,=20a=20package=20artifact;=20the=20view's?= =?UTF-8?q?=20answer=20key=20and=20fit=20in=20viewstate=20(ADR=200634=20?= =?UTF-8?q?=C2=A71,=20=C2=A74,=20ADR=200635=20=C2=A75;=20issue=201966)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- modules/mesh-delivery-assigner/asks/asks.go | 257 ++++++++++++++++++ .../mesh-delivery-assigner/asks/asks_test.go | 85 ++++++ modules/mesh-delivery-assigner/asks/go.mod | 13 + modules/mesh-delivery-assigner/module.json | 9 + .../mesh-delivery-view/viewstate/answerkey.go | 48 ++++ modules/mesh-delivery-view/viewstate/go.mod | 2 + .../viewstate/viewstate_test.go | 28 ++ 7 files changed, 442 insertions(+) create mode 100644 modules/mesh-delivery-assigner/asks/asks.go create mode 100644 modules/mesh-delivery-assigner/asks/asks_test.go create mode 100644 modules/mesh-delivery-assigner/asks/go.mod create mode 100644 modules/mesh-delivery-view/viewstate/answerkey.go diff --git a/modules/mesh-delivery-assigner/asks/asks.go b/modules/mesh-delivery-assigner/asks/asks.go new file mode 100644 index 00000000..d255408b --- /dev/null +++ b/modules/mesh-delivery-assigner/asks/asks.go @@ -0,0 +1,257 @@ +// Package asks holds the layout of the assigner's state `asks`: the keys of the asks the assigner puts to a person and the +// value it keeps at each, which the assigner writes and the view and the teller read (novox/hq ADR 0634 §1, ADR 0635 §5). It +// is the assigner's own published package. The gate's merge ask (`merge..`) 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), the restore of a data module set back (`restore...`: restore and lose newer +// writes, or leave it stopped, ADR 0429 §6), a backup that failed before an apply, a move a person must allow for a module of +// policy record, and the authorising ask of a proposal of placement. +package asks + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient" + "git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient" + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds" + "git.novox.be/novox/mesh-delivery/sdk/kept" + "git.novox.be/novox/mesh-sdk/go/keys" +) + +const ( + // 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" + // AskKindBackup is the kind of the assigner's ask about a backup before an apply that failed: back up again, or go on + // without one (novox/hq ADR 0539 clause 16). + AskKindBackup = "backup" + // AskKindRecord is the kind of the assigner's ask about a move a person must allow, for a module of policy record: a + // composition judged to recreate a container or move data, or one that cannot be judged (ADR 0539 clause 14). + AskKindRecord = "record" + // AskKindPlacement is the kind of the authorising ask about a proposal of placement (novox/hq ADR 0567 §5): + // `placement.`. The kind needs no proof, as the bus's put does not: its yes is an Approve, which only a + // channel proving who answered takes and the view never does (ViewAnswerFits). + AskKindPlacement = "placement" +) + +// 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 and a backup's Node, Module and FP, a record's Node, Module and the digest Composed of the +// composition it would allow, a placement's Proposal. +type AskKey struct { + Proposal string + Kind string + Group string + Hash string + Node string + Module string + FP string + Composed 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 meshdeliveryclient.AskKindMerge: + return true, nil + case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindPlacement: + return false, nil + } + return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold, + AskKindRestore, AskKindBackup, AskKindRecord, AskKindPlacement) +} + +// HoldAskKey is the key of the ask about a module's held fingerprint: `hold..`. +func HoldAskKey(module, fingerprint string) (string, error) { + key, err := builds.BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindHold + "." + key, nil +} + +// RestoreAskKey is the key 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 := keys.CheckName("node", node); err != nil { + return "", err + } + key, err := builds.BuilderBuildKey(module, fingerprint) + if err != nil { + return "", err + } + return AskKindRestore + "." + node + "." + key, nil +} + +// BackupAskKey is the key of the ask about a module's failed backup on a node before it applies a fingerprint: +// `backup...`. +func BackupAskKey(node, module, fingerprint string) (string, error) { + part, err := meshcatalogclient.KeyPart(fingerprint) + if err != nil { + return "", err + } + key, err := nodeengineclient.ComposedKey(node, module, part) + if err != nil { + return "", err + } + return AskKindBackup + "." + key, nil +} + +// PlacementAskKey is the key of the authorising ask about a proposal of placement: `placement.` (ADR 0567 §5). +func PlacementAskKey(proposal string) (string, error) { + if err := meshmoduleassignerclient.CheckProposalID(proposal); err != nil { + return "", err + } + return AskKindPlacement + "." + proposal, nil +} + +// RecordAskKey is the key of the ask about a move of a module on a node to the composition whose digest it names: +// `record...`, so a new revision at the same fingerprint is asked again (ADR 0539 clause 14). +func RecordAskKey(node, module, composed string) (string, error) { + have, err := nodeengineclient.HaveKey(node, module) + if err != nil { + return "", err + } + if err := kept.CheckDigest(composed, "a record ask's composition digest"); err != nil { + return "", err + } + return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil +} + +// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record or placement ask); 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("asks: %q is not an ask key: %w", key, err) + } + switch kind { + case meshdeliveryclient.AskKindMerge: + _, group, hash, err := meshdeliveryclient.ParseAskKey(key) + if err != nil { + return AskKey{}, err + } + return AskKey{Kind: kind, Group: group, Hash: hash}, nil + case AskKindHold: + module, fp, err := builds.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 := keys.CheckName("node", node); err != nil { + return refused(err) + } + module, fp, err := builds.ParseBuilderBuildKey(build) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + case AskKindBackup: + node, module, part, isNode, err := nodeengineclient.ParseComposedKey(rest) + if err != nil || isNode { + return refused(fmt.Errorf("a backup ask is backup...")) + } + fp, err := meshcatalogclient.ParseKeyPart(part) + if err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil + case AskKindPlacement: + if err := meshmoduleassignerclient.CheckProposalID(rest); err != nil { + return refused(err) + } + return AskKey{Kind: kind, Proposal: rest}, nil + case AskKindRecord: + parts := strings.Split(rest, ".") + if len(parts) != 3 { + return refused(fmt.Errorf("a record ask is record...")) + } + node, module, isNode, err := nodeengineclient.ParseHaveKey(parts[0] + "." + parts[1]) + if err != nil || isNode { + return refused(fmt.Errorf("a record ask is record...")) + } + composed := "sha256:" + parts[2] + if err := kept.CheckDigest(composed, "a record ask's composition digest"); err != nil { + return refused(err) + } + return AskKey{Kind: kind, Node: node, Module: module, Composed: composed}, nil + } + _, err := AskNeedsProof(kind) + return refused(err) +} + +// Ask is the value of a key in the assigner's state asks: 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 meshdeliveryclient.AskingID(key, Asking), so each asking has its own id and +// an answer to an earlier asking is never taken for a later one. The ask is the operator-channel seat's as its JSON: its id, +// its options and its digest are checked by the assigner and the teller through operator-channel's client (novox/hq ADR +// 0627 §4). +type Ask struct { + V int `json:"v"` + Ask json.RawMessage `json:"ask"` + Asking int `json:"asking,omitempty"` +} + +func (a Ask) check(key string) error { + ask, err := ParseAnyAskKey(key) + if err != nil { + return err + } + if proof, _ := AskNeedsProof(ask.Kind); proof { + return fmt.Errorf("asks: the assigner asks no %s ask: it needs proof of who answered", ask.Kind) + } + if a.Asking < 0 { + return fmt.Errorf("asks: the ask's asking %d is negative", a.Asking) + } + var fields map[string]json.RawMessage + if len(bytes.TrimSpace(a.Ask)) == 0 || json.Unmarshal(a.Ask, &fields) != nil || fields == nil { + return fmt.Errorf("asks: the value carries no router's ask") + } + return nil +} + +// EncodeAsk is a, the ask at key, as JSON at keys.Version; it refuses a key of a kind that needs proof or of no kind, a +// negative asking, and a value carrying no router's ask. +func EncodeAsk(key string, a Ask) ([]byte, error) { + if err := a.check(key); err != nil { + return nil, err + } + a.V = keys.Version + return json.Marshal(a) +} + +// DecodeAsk reads the ask at key; it refuses an unknown version (keys.ErrUnknownVersion) and what EncodeAsk refuses. +func DecodeAsk(key string, data []byte) (Ask, error) { + var a Ask + if err := kept.Decode(data, &a); err != nil { + return Ask{}, err + } + if err := a.check(key); err != nil { + return Ask{}, err + } + return a, nil +} + +// SameAsk says whether two asks are the same value: the same version, asking and router's ask, its JSON compared compacted. +func SameAsk(a, b Ask) bool { + return a.V == b.V && a.Asking == b.Asking && bytes.Equal(compacted(a.Ask), compacted(b.Ask)) +} + +func compacted(raw json.RawMessage) []byte { + var b bytes.Buffer + if json.Compact(&b, raw) != nil { + return raw + } + return b.Bytes() +} diff --git a/modules/mesh-delivery-assigner/asks/asks_test.go b/modules/mesh-delivery-assigner/asks/asks_test.go new file mode 100644 index 00000000..bfa79231 --- /dev/null +++ b/modules/mesh-delivery-assigner/asks/asks_test.go @@ -0,0 +1,85 @@ +package asks + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/keys" +) + +// The keys equal what the assigner has always written (the expected strings are the trunk's own output for the same input), +// and each reads back to its parts. +func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) { + fp := "src2:" + strings.Repeat("ab", 32) + hex := strings.Repeat("ab", 32) + digest := "sha256:" + strings.Repeat("cd", 32) + hold, _ := HoldAskKey("gitea", fp) + restore, _ := RestoreAskKey("n1", "gitea", fp) + backup, _ := BackupAskKey("n1", "novox.be", fp) + placement, _ := PlacementAskKey("p-20261028t183000-1a2b3c") + record, _ := RecordAskKey("n1", "gitea", digest) + for key, want := range map[string]struct { + got string + back AskKey + }{ + "hold": {hold, AskKey{Kind: "hold", Module: "gitea", FP: fp}}, + "restore": {restore, AskKey{Kind: "restore", Node: "n1", Module: "gitea", FP: fp}}, + "backup": {backup, AskKey{Kind: "backup", Node: "n1", Module: "novox.be", FP: fp}}, + "placement": {placement, AskKey{Kind: "placement", Proposal: "p-20261028t183000-1a2b3c"}}, + "record": {record, AskKey{Kind: "record", Node: "n1", Module: "gitea", Composed: digest}}, + } { + back, err := ParseAnyAskKey(want.got) + if err != nil || back != want.back { + t.Errorf("%s: ParseAnyAskKey(%q) = %+v, %v; want %+v", key, want.got, back, err, want.back) + } + } + for got, want := range map[string]string{hold: "hold.gitea." + hex, restore: "restore.n1.gitea." + hex, backup: "backup.n1.novox=2ebe." + hex, + placement: "placement.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32)} { + if got != want { + t.Errorf("key %q, want %q", got, want) + } + } + merge, err := ParseAnyAskKey("merge.fix/1=2e2.d106c7950d28") + if err != nil || merge.Kind != "merge" || merge.Group != "fix/1.2" || merge.Hash != "d106c7950d28" { + t.Errorf("a merge ask key: %+v, %v", merge, err) + } + for _, bad := range []string{"", "other.x", "hold.gitea", "hold.gitea.src2:" + hex, "restore.n1", "backup.n1.node", "backup.n1.gitea." + hex[:60], + "placement.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g"} { + if _, err := ParseAnyAskKey(bad); err == nil { + t.Errorf("ParseAnyAskKey(%q) reads a wrong key", bad) + } + } + if proof, err := AskNeedsProof("merge"); err != nil || !proof { + t.Error("a merge ask needs proof") + } + if proof, err := AskNeedsProof("hold"); err != nil || proof { + t.Error("a hold ask needs no proof") + } + if _, err := AskNeedsProof("view"); err == nil { + t.Error("an unknown kind of ask is taken") + } + + // The value: the router's ask whole; an ask of a kind that needs proof, a negative asking and no ask are refused. + good := Ask{Ask: []byte(`{"id":"x"}`), Asking: 2} + data, err := EncodeAsk(hold, good) + if err != nil { + t.Fatalf("EncodeAsk: %v", err) + } + back, err := DecodeAsk(hold, data) + if err != nil || !SameAsk(back, Ask{V: keys.Version, Ask: []byte(` {"id": "x"} `), Asking: 2}) { + t.Errorf("DecodeAsk = %+v, %v", back, err) + } + for name, c := range map[string]struct { + key string + a Ask + }{"a merge ask": {"merge.g.d106c7950d28", good}, "a negative asking": {hold, Ask{Ask: good.Ask, Asking: -1}}, "no ask": {hold, Ask{}}, + "a null ask": {hold, Ask{Ask: []byte("null")}}, "an unknown kind": {"other.x", good}} { + if _, err := EncodeAsk(c.key, c.a); err == nil { + t.Errorf("EncodeAsk takes %s", name) + } + } + if _, err := DecodeAsk(hold, []byte(`{"v":2,"ask":{}}`)); !errors.Is(err, keys.ErrUnknownVersion) { + t.Errorf("DecodeAsk of another version = %v", err) + } +} diff --git a/modules/mesh-delivery-assigner/asks/go.mod b/modules/mesh-delivery-assigner/asks/go.mod new file mode 100644 index 00000000..fbda6c1d --- /dev/null +++ b/modules/mesh-delivery-assigner/asks/go.mod @@ -0,0 +1,13 @@ +module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks + +go 1.26.0 + +require ( + git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0 + git.novox.be/novox/mesh-contracts/placement/go/clients/meshmoduleassignerclient v0.0.0 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0 + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0 + git.novox.be/novox/mesh-delivery/sdk v0.0.0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f +) diff --git a/modules/mesh-delivery-assigner/module.json b/modules/mesh-delivery-assigner/module.json index 423a565a..97a163ae 100644 --- a/modules/mesh-delivery-assigner/module.json +++ b/modules/mesh-delivery-assigner/module.json @@ -158,6 +158,15 @@ "MESH_VIEW_CALLERS_SEAT_HOLDERS": "node-tools", "MESH_VIEW_CALLERS_WANTS": "mesh-grants" } + }, + { + "name": "asks", + "kind": "package", + "language": "go", + "paths": [ + "asks" + ], + "series": "0.1" } ] } diff --git a/modules/mesh-delivery-view/viewstate/answerkey.go b/modules/mesh-delivery-view/viewstate/answerkey.go new file mode 100644 index 00000000..7d7d8a09 --- /dev/null +++ b/modules/mesh-delivery-view/viewstate/answerkey.go @@ -0,0 +1,48 @@ +package viewstate + +import ( + "fmt" + "strings" + + "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks" +) + +const viewWord = "view" + +// ViewAnswerKey is the key, in the state answers, 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 := asks.ParseAnyAskKey(askKey) + if err != nil { + return "", err + } + if proof, _ := asks.AskNeedsProof(ask.Kind); proof { + return "", fmt.Errorf("viewstate: 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 state, naming it. +func ParseViewAnswerKey(key string) (askKey string, err error) { + askKey, ok := strings.CutSuffix(key, "."+viewWord) + if !ok { + return "", fmt.Errorf("viewstate: %q is not a view answer key, .view", key) + } + if _, err := ViewAnswerKey(askKey); err != nil { + return "", fmt.Errorf("viewstate: %q is not a view answer key: %w", key, err) + } + return askKey, nil +} + +// ViewAnswerFits says whether the view's answer a answers the ask at key 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), naming both. Whether the ask offers the option +// chosen, and at the level acknowledge only (an option that authorises is answered on a channel that proves who answered, +// never in the view, which proves nobody), the teller reads from the ask through operator-channel's client. +func ViewAnswerFits(a ViewAnswer, key string, ask asks.Ask) error { + if want := meshdeliveryclient.AskingID(key, ask.Asking); a.Asking != want { + return fmt.Errorf("viewstate: the view's answer is to the asking %s, and the ask stands at the asking %s", a.Asking, want) + } + return nil +} diff --git a/modules/mesh-delivery-view/viewstate/go.mod b/modules/mesh-delivery-view/viewstate/go.mod index 5a17cb64..9c9477e4 100644 --- a/modules/mesh-delivery-view/viewstate/go.mod +++ b/modules/mesh-delivery-view/viewstate/go.mod @@ -3,6 +3,8 @@ module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate go 1.26.0 require ( + git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0 + git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks v0.0.0 git.novox.be/novox/mesh-delivery/sdk v0.0.0 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104174359-05b3e2e1b94f ) diff --git a/modules/mesh-delivery-view/viewstate/viewstate_test.go b/modules/mesh-delivery-view/viewstate/viewstate_test.go index c35726bb..9863ec8d 100644 --- a/modules/mesh-delivery-view/viewstate/viewstate_test.go +++ b/modules/mesh-delivery-view/viewstate/viewstate_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" + "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-sdk/go/keys" ) @@ -72,6 +74,32 @@ func TestReachValuesDifferingInAnyPartAreNotTheSame(t *testing.T) { } func TestViewAnswerRoundTrip(t *testing.T) { + t.Run("the key and the asking", func(t *testing.T) { + fp := "src2:" + strings.Repeat("ab", 32) + hold, _ := asks.HoldAskKey("gitea", fp) + 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) + } + for _, bad := range []string{hold, "merge.g.d106c7950d28.view", "other.x.view"} { + if _, err := ParseViewAnswerKey(bad); err == nil { + t.Errorf("ParseViewAnswerKey(%q) reads a wrong key", bad) + } + } + if _, err := ViewAnswerKey("merge.g.d106c7950d28"); err == nil || !strings.Contains(err.Error(), "proof") { + t.Errorf("a merge ask is answered by the view: %v", err) + } + first := ViewAnswer{Asking: meshdeliveryclient.AskingID(hold, 0), Option: "release"} + if err := ViewAnswerFits(first, hold, asks.Ask{}); err != nil { + t.Errorf("an answer to the first asking is refused: %v", err) + } + if err := ViewAnswerFits(first, hold, asks.Ask{Asking: 2}); err == nil { + t.Error("an answer to an earlier asking is taken for the later one") + } + }) a := ViewAnswer{Asking: "hold-mesh-mail-aaaa-0123456789ab", Option: "release", Words: "fixed by #12"} data, err := EncodeViewAnswer(a) if err != nil {