Merge pull request 'The put's words: delivery.Put, a want's put and hold marks, a grant that says which node's accounts it names (ADR 0546)' (#82) from feat/put-version into main

This commit was merged in pull request #82.
This commit is contained in:
2026-10-25 20:35:28 +00:00
6 changed files with 243 additions and 3 deletions
+12 -2
View File
@@ -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
}
+52
View File
@@ -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)
}
}
}
+17
View File
@@ -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 `<node>.<module>` or a process's
// `<node>.<module>.<process>`. 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
}
+148
View File
@@ -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 `<node>.<module>` 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, `<owner module>_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: `<node>.<module>`.
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
}
+7 -1
View File
@@ -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
}
+7
View File
@@ -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)
}
}