diff --git a/go/delivery/assigner.go b/go/delivery/assigner.go index b009088..4aff2fd 100644 --- a/go/delivery/assigner.go +++ b/go/delivery/assigner.go @@ -181,15 +181,25 @@ type Want struct { Rule int `json:"rule,omitempty"` Backup string `json:"backup,omitempty"` Words string `json:"words,omitempty"` + // Put says the want was written by a put (ADR 0546), and Hold that the put holds it: the rules pass over it until it + // is released. A put's want names no newest fingerprint or rule. + Put bool `json:"put,omitempty"` + Hold bool `json:"hold,omitempty"` } func (w Want) check() error { if w.Removed { - if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" { - return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule or backup answer") + if w.FP != "" || w.Composed != "" || w.Newest != "" || w.Rule != 0 || w.Backup != "" || w.Put || w.Hold { + return fmt.Errorf("delivery: a removed want names no fingerprint, composition, rule, backup answer or put") } return nil } + if w.Hold && !w.Put { + return fmt.Errorf("delivery: only a put holds a want") + } + if w.Put && (w.Newest != "" || w.Rule != 0) { + return fmt.Errorf("delivery: a put's want is held back by no rule") + } if err := checkFingerprint(w.FP, "a want's fingerprint"); err != nil { return err } diff --git a/go/delivery/assigner_test.go b/go/delivery/assigner_test.go index a63f108..6b54e1d 100644 --- a/go/delivery/assigner_test.go +++ b/go/delivery/assigner_test.go @@ -4,6 +4,7 @@ import ( "errors" "strings" "testing" + "time" ) // 1297/B1a: a want key round-trips through its parser; a held key, a have key and a node's own key are refused, @@ -77,6 +78,7 @@ func TestAssignerValuesRoundTrip(t *testing.T) { {FP: fp2of('a'), Composed: hashOf('1')}, {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: RuleCanary, Words: "the canary ace does not run it healthy yet"}, {Removed: true, Words: "the module left g14"}, + {FP: fp2of('a'), Composed: hashOf('1'), Put: true, Hold: true, Words: "put by the operator: the bus's user list"}, } for _, w := range wants { data, err := EncodeWant(w) @@ -89,6 +91,24 @@ func TestAssignerValuesRoundTrip(t *testing.T) { t.Errorf("want %+v came back %+v, %v", w, got, err) } } + at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC) + p := Put{FP: fp2of('a'), Hold: true, Why: "the bus's user list", By: "shanks.node-tools", At: at}.Said(at, PutAsked, "put asked") + p = p.Said(at, PutAsked, "put asked").Said(at.Add(time.Second), PutWanted, "want written") + p.Composed = hashOf('1') + if len(p.Steps) != 2 || p.Step != PutWanted || PutEnded(p.Step) || !PutEnded(PutReleased) { + t.Errorf("a put's steps are %+v at %s", p.Steps, p.Step) + } + pkey, _ := PutKey("novox", "nats") + if n, m, err := ParsePutKey(pkey); err != nil || n != "novox" || m != "nats" { + t.Errorf("the put key %s reads %s %s, %v", pkey, n, m, err) + } + pdata, err := EncodePut(p) + if err != nil { + t.Fatal(err) + } + if gotP, err := DecodePut(pdata); err != nil || gotP.FP != p.FP || gotP.Step != PutWanted || len(gotP.Steps) != 2 || !gotP.Hold { + t.Errorf("the put came back %+v, %v", gotP, err) + } h := Held{Node: "ace", Words: "its health check failed: connection refused"} data, err := EncodeHeld(h) if err != nil { @@ -128,6 +148,9 @@ func TestAssignerValuesRefused(t *testing.T) { {FP: fp2of('a'), Newest: fp2of('b')}, {FP: fp2of('a'), Newest: fp2of('a'), Rule: 3}, {FP: fp2of('a'), Composed: hashOf('1'), Newest: fp2of('b'), Rule: 8}, + {FP: fp2of('a'), Composed: hashOf('1'), Hold: true}, + {FP: fp2of('a'), Composed: hashOf('1'), Put: true, Newest: fp2of('b'), Rule: 3}, + {Removed: true, Put: true}, } { if _, err := EncodeWant(w); err == nil { t.Errorf("EncodeWant(%+v) accepted", w) @@ -148,6 +171,19 @@ func TestAssignerValuesRefused(t *testing.T) { t.Errorf("EncodeDifference(%+v) accepted", d) } } + for _, p := range []Put{ + {FP: fp2of('a'), Why: "w", Step: "walking"}, + {FP: fp2of('a'), Step: PutAsked}, + {FP: "src2:abc", Why: "w", Step: PutAsked}, + {FP: fp2of('a'), Why: "w", Step: PutWanted, Composed: "sha256:x"}, + } { + if _, err := EncodePut(p); err == nil { + t.Errorf("EncodePut(%+v) accepted", p) + } + } + if _, _, err := ParsePutKey("ace.node"); err == nil { + t.Errorf("a node's own key was read as a put") + } if _, err := DecodeWant([]byte(`{"v":2,"fp":"` + fp2of('a') + `"}`)); !errors.Is(err, ErrUnknownVersion) { t.Errorf("a want of version 2 was read: %v", err) } @@ -262,4 +298,20 @@ func TestControllerGrant(t *testing.T) { if _, err := EncodeControllerGrant(ControllerGrant{Accounts: []string{""}}); err == nil { t.Errorf("a grant naming an empty account was accepted") } + g := ControllerGrant{Accounts: []string{"ace.mesh-build-go", "shanks.mesh-mail.reader"}} + for _, c := range []struct { + g ControllerGrant + node, module string + names bool + }{ + {g, "ace", "mesh-build-go", true}, + {g, "shanks", "mesh-mail", true}, + {g, "g14", "mesh-build-go", false}, + {g, "ace", "mesh-build", false}, + {ControllerGrant{}, "novox", "nats", true}, + } { + if got := c.g.NamesNode(c.node, c.module); got != c.names { + t.Errorf("%v names %s.%s: %v, want %v", c.g.Accounts, c.node, c.module, got, c.names) + } + } } diff --git a/go/delivery/grants.go b/go/delivery/grants.go index 39e602c..884e4f1 100644 --- a/go/delivery/grants.go +++ b/go/delivery/grants.go @@ -73,3 +73,20 @@ func DecodeControllerGrant(data []byte) (ControllerGrant, error) { // SameControllerGrant says whether two grants are the same value. func SameControllerGrant(a, b ControllerGrant) bool { return reflect.DeepEqual(a, b) } + +// NamesNode says whether the grant names module's accounts on node: its own `.` or a process's +// `..`. A grant that names no account (a module that does not speak on the bus) names every +// node. The assigner wants a node only once the grant names its account (issue 1945), and a put waits for it (ADR +// 0546 clause 4). +func (g ControllerGrant) NamesNode(node, module string) bool { + if len(g.Accounts) == 0 { + return true + } + own := node + "." + module + for _, a := range g.Accounts { + if a == own || strings.HasPrefix(a, own+".") { + return true + } + } + return false +} diff --git a/go/delivery/put.go b/go/delivery/put.go new file mode 100644 index 0000000..609d298 --- /dev/null +++ b/go/delivery/put.go @@ -0,0 +1,148 @@ +package delivery + +import ( + "encoding/json" + "fmt" + "time" +) + +// A put (novox/hq ADR 0546): one version of a module put on one node by the assigner's tool `assigner_put`. Each put is the +// key `.` of the assigner's state puts, which holds the version asked, the hold, why, and each step the +// assigner took or waits on, so a reader watches the key to follow it. The want the put writes carries Put (and Hold), +// so the assigner's rules pass over that key while the put holds it. + +// AssignerPutsBucket is the assigner's bucket of puts, `_puts`. +const AssignerPutsBucket = AssignerModule + "_puts" + +// AssignerPutsHistory is the revisions the puts bucket keeps per key: each step of a put is one. +const AssignerPutsHistory = 20 + +// A put's steps, in the order they are taken (ADR 0546 clauses 3 to 9). A put stands at the last step written. +const ( + // PutAsked is a put written by the tool, not taken up by a pass yet. + PutAsked = "asked" + // PutBuild waits for the builder's build at the fingerprint. + PutBuild = "build" + // PutCompose waits for the controller's composition (and grant) at the fingerprint. + PutCompose = "compose" + // PutPerson waits for the operator's answer to the ask about the move. + PutPerson = "person" + // PutSnapshot waits for the bus's streams to be snapshotted after the operator's yes. + PutSnapshot = "snapshot" + // PutWanted is the want written: the node's have has not reached it yet. + PutWanted = "wanted" + // PutHolding is a held put whose have runs it healthy: the want stays until released. + PutHolding = "holding" + // PutDone is a put without hold whose have ran it healthy: the rules write the want again. + PutDone = "done" + // PutReleased is a put released by the tool `assigner_release`. + PutReleased = "released" + // PutRefused is a put that cannot go on: a build that failed, an answer no, a snapshot not taken. Its words say why. + PutRefused = "refused" +) + +// PutEnded says a put at this step is over: nothing more is done for it and the rules own the want. +func PutEnded(step string) bool { + return step == PutDone || step == PutReleased || step == PutRefused +} + +// PutStep is one step a put took: when, which, and what was done or waits, in a sentence. +type PutStep struct { + At time.Time `json:"at"` + Step string `json:"step"` + Words string `json:"words"` +} + +// PutStepsKept is how many steps a put keeps, newest last. +const PutStepsKept = 16 + +// Put is the value of a key of AssignerPutsBucket. +type Put struct { + V int `json:"v"` + // FP is the fingerprint put, of the new form. + FP string `json:"fp"` + // Hold keeps the want at FP until released. + Hold bool `json:"hold,omitempty"` + Why string `json:"why"` + // By is who asked, as the bus named the caller; "unproven" when it named none. + By string `json:"by,omitempty"` + At time.Time `json:"at"` + // Step is where the put stands now, one of the Put* steps; Words say it in a sentence. + Step string `json:"step"` + Words string `json:"words,omitempty"` + // Composed is the digest of the composition the want names, once known. + Composed string `json:"composed,omitempty"` + // Ask is the key, in AssignerAsksBucket, of the ask about the move, once asked. + Ask string `json:"ask,omitempty"` + // Snapshot says where the bus's streams were snapshotted, once they were. + Snapshot string `json:"snapshot,omitempty"` + // Steps are the steps taken, oldest first, at most PutStepsKept. + Steps []PutStep `json:"steps,omitempty"` +} + +// Said is p at step with words, the step added to its steps when it differs from the last one. +func (p Put) Said(at time.Time, step, words string) Put { + if p.Step == step && p.Words == words { + return p + } + p.Step, p.Words = step, words + p.Steps = append(append([]PutStep(nil), p.Steps...), PutStep{At: at.UTC(), Step: step, Words: words}) + if len(p.Steps) > PutStepsKept { + p.Steps = p.Steps[len(p.Steps)-PutStepsKept:] + } + return p +} + +func (p Put) check() error { + if err := checkFingerprint(p.FP, "a put's fingerprint"); err != nil { + return err + } + if p.Why == "" { + return fmt.Errorf("delivery: a put says why") + } + switch p.Step { + case PutAsked, PutBuild, PutCompose, PutPerson, PutSnapshot, PutWanted, PutHolding, PutDone, PutReleased, PutRefused: + default: + return fmt.Errorf("delivery: %q is not a step of a put", p.Step) + } + if p.Composed != "" { + return checkDigest(p.Composed, "a put's composition digest") + } + return nil +} + +// PutKey is the key, in AssignerPutsBucket, of the put of module on node: `.`. +func PutKey(node, module string) (string, error) { + return HaveKey(node, module) +} + +// ParsePutKey reads a PutKey. +func ParsePutKey(key string) (node, module string, err error) { + node, module, isNode, err := ParseHaveKey(key) + if err == nil && isNode { + err = fmt.Errorf("delivery: %q is a node's own key, and no put", key) + } + return node, module, err +} + +// EncodePut is p as JSON at Version; it refuses a fingerprint not of the new form, a put that says no why and an +// unknown step. +func EncodePut(p Put) ([]byte, error) { + if err := p.check(); err != nil { + return nil, err + } + p.V = Version + return json.Marshal(p) +} + +// DecodePut reads a put; it refuses an unknown version (ErrUnknownVersion) and what EncodePut refuses. +func DecodePut(data []byte) (Put, error) { + var p Put + if err := decode(data, &p); err != nil { + return Put{}, err + } + if err := p.check(); err != nil { + return Put{}, err + } + return p, nil +} diff --git a/go/delivery/view_answers.go b/go/delivery/view_answers.go index 68561fc..5109830 100644 --- a/go/delivery/view_answers.go +++ b/go/delivery/view_answers.go @@ -283,9 +283,15 @@ 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 { + o, ok := ask.Ask.Option(a.Option) + if !ok { return fmt.Errorf("delivery: the view's answer chose %q, which the asking %s does not offer", a.Option, ask.Ask.ID) } + if o.Level != "" && o.Level != asks.Acknowledge { + // An option that authorises is answered on a channel that proves who answered (ADR 0259 ยง5, ADR 0546 clause 8), + // never in the view, which proves nobody. + return fmt.Errorf("delivery: the view's answer chose %q, an option of level %s, which only a channel that proves who answered takes", a.Option, o.Level) + } return nil } diff --git a/go/delivery/view_answers_test.go b/go/delivery/view_answers_test.go index c069e01..1a9b824 100644 --- a/go/delivery/view_answers_test.go +++ b/go/delivery/view_answers_test.go @@ -158,4 +158,11 @@ func TestViewAnswerFits(t *testing.T) { if err := ViewAnswerFits(ViewAnswer{Asking: first.Ask.ID, Option: "restore"}, first); err == nil { t.Errorf("an option the ask does not offer fits") } + // An option that authorises is never answered in the view (ADR 0546 clause 8). + approving := first + approving.Ask.Options = append([]asks.Option(nil), first.Ask.Options...) + approving.Ask.Options[0].Level = asks.Approve + if err := ViewAnswerFits(answer, approving); err == nil || !strings.Contains(err.Error(), "approve") { + t.Errorf("the view answered an option of level approve: %v", err) + } }