Bind each asked option to the exact act, and perform only that act on its warrant (hq ADR 0259 §6)

Every option the controller asks with carries the digest of its verb, machine, arguments and level
(the SDK's Option.Binds). A warrant must name the digest of the ask the controller keeps, and before
acting the controller checks that the act it is about to perform is the one the option bound: a
record changed after the ask is refused, never performed. mesh-sdk moves to d4077b4.
This commit is contained in:
2026-10-09 12:24:53 +02:00
parent 8391b742a8
commit 9f4f551335
6 changed files with 125 additions and 6 deletions
+17 -1
View File
@@ -373,7 +373,11 @@ func askOf(id string, c conditions.Condition, now time.Time) (asks.Ask, map[stri
approves = approves || level != asks.Acknowledge approves = approves || level != asks.Acknowledge
oid := optionID(act.Label) oid := optionID(act.Label)
options[oid] = i options[oid] = i
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesWords(act), Level: level}) // Every option binds the exact act it stands for (novox/hq ADR 0259 §6): the verb, the machine and
// every argument. The warrant then authorises that act and no other.
binds, _ := asks.ActDigest(boundAct(act))
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: doesWords(act), Level: level,
Binds: binds})
} }
q.Expires = now.Add(askAcknowledgeFor) q.Expires = now.Add(askAcknowledgeFor)
if approves { if approves {
@@ -382,6 +386,12 @@ func askOf(id string, c conditions.Condition, now time.Time) (asks.Ask, map[stri
return q, options return q, options
} }
// boundAct is what an option's Binds digests: the act exactly as the controller will perform it, with its
// level, and never its label or words.
func boundAct(act conditions.Action) map[string]any {
return map[string]any{"verb": act.Verb, "machine": act.Machine, "arguments": act.Arguments, "level": act.Level}
}
func newAskID() string { func newAskID() string {
var b [8]byte var b [8]byte
_, _ = rand.Read(b[:]) _, _ = rand.Read(b[:])
@@ -469,6 +479,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
return nil return nil
} }
act := r.Actions[index] act := r.Actions[index]
// The act about to be performed is the one the option bound when the controller asked: a record changed
// since is refused, never performed.
if err := option.Performs(boundAct(act)); err != nil {
a.logf("REFUSED a warrant for the ask %s: %v", r.ID, err)
return nil
}
r.State, r.Warrant = string(asks.OutcomeChosen), &w r.State, r.Warrant = string(asks.OutcomeChosen), &w
open, err := a.open(ctx) open, err := a.open(ctx)
if err != nil { if err != nil {
+41 -1
View File
@@ -188,7 +188,8 @@ func (r *askerRig) warrantFor(t *testing.T, condition, label string) asks.Warran
if o.Label == label { if o.Label == label {
return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: condition, Outcome: asks.OutcomeChosen, return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: condition, Outcome: asks.OutcomeChosen,
Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now, Option: o.ID, Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: r.now,
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}} AskDigest: a.Ask.Digest(),
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
} }
} }
} }
@@ -242,6 +243,8 @@ func TestAWarrantThatIsNotForItsOwnAskIsRefused(t *testing.T) {
"an option not offered": func(w *asks.Warrant) { w.Option = "delete" }, "an option not offered": func(w *asks.Warrant) { w.Option = "delete" },
"another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge },
"no person": func(w *asks.Warrant) { w.By = nil }, "no person": func(w *asks.Warrant) { w.By = nil },
"another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:0000" },
"no ask's digest": func(w *asks.Warrant) { w.AskDigest = "" },
} { } {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
r := newAskerRig(t) r := newAskerRig(t)
@@ -474,3 +477,40 @@ func TestAWarrantIsActedOnlyForAnOpenAskItClaimsBeforeItExpired(t *testing.T) {
t.Errorf("acted on a cancelled ask: %v", r.called) t.Errorf("acted on a cancelled ask: %v", r.called)
} }
} }
// novox/hq ADR 0259 §6: a warrant authorises the act its option bound when the controller asked, and no
// other. A record of the act changed after the ask — another delivery, another machine, another argument —
// is refused and nothing is performed.
func TestAWarrantPerformsOnlyTheActItsOptionBound(t *testing.T) {
for name, change := range map[string]func(*conditions.Action){
"another argument": func(a *conditions.Action) {
a.Arguments = map[string]string{"id": "novox/mesh-controller@000000000000"}
},
"another verb": func(a *conditions.Action) { a.Verb = "mesh-delivery.stop" },
"another machine": func(a *conditions.Action) { a.Machine = "anchor" },
} {
t.Run(name, func(t *testing.T) {
r := newAskerRig(t)
r.open = []conditions.Condition{heldCondition()}
_ = r.a.reconcile(context.Background())
w := r.warrantFor(t, heldCondition().Key, "Release")
kept := r.store[w.Ask]
acts := append([]conditions.Action(nil), kept.Actions...)
i := kept.Options[w.Option]
change(&acts[i])
kept.Actions = acts
r.store[w.Ask] = kept
answerWith(t, r, w)
if len(r.called)+len(r.acts) != 0 {
t.Errorf("performed an act the option did not bind: %v %v", r.called, r.acts)
}
})
}
// Every option of an ask binds its act.
q, _ := askOf("x", heldCondition(), time.Now())
for _, o := range q.Options {
if o.Binds == "" {
t.Errorf("the option %s binds nothing", o.ID)
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2
View File
@@ -6,6 +6,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6 h1:JT7xM1bnL
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008145004-62367ce15ad6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f h1:BNvyWq899GwP7F3sY4ACieB5a5fnFAq+sJ9lP6HQ5qI=
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8 h1:soqhLNpEXThdq6PdiPy6ExxjJ+yjhh1N1n9E3j1CtrM=
git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+63 -2
View File
@@ -6,6 +6,9 @@
package asks package asks
import ( import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
@@ -96,6 +99,34 @@ type Option struct {
Label string `json:"label"` Label string `json:"label"`
Does string `json:"does"` Does string `json:"does"`
Level Level `json:"level"` Level Level `json:"level"`
// Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the
// machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant,
// which names the ask's digest, names it too: a warrant then authorises that act and no other, and an
// asker whose record of the act changed after it asked finds the digests differ and does nothing.
// Required on an option above acknowledge.
Binds string `json:"binds,omitempty"`
}
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's JSON (Go's encoding sorts a
// map's keys, so the same act gives the same digest), written "sha256:<hex>".
func ActDigest(act any) (string, error) {
raw, err := json.Marshal(act)
if err != nil {
return "", fmt.Errorf("the act cannot be digested: %w", err)
}
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each
// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker
// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the
// person was shown it, and nothing published under the same id before or after.
func (a Ask) Digest() string {
a.Expires = a.Expires.UTC()
raw, _ := json.Marshal(a)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
} }
// Ask is a request for a person's word (novox/hq ADR 0259 §4). // Ask is a request for a person's word (novox/hq ADR 0259 §4).
@@ -191,6 +222,9 @@ func (a Ask) Check(now time.Time) error {
if o.Level.Rank() > Destroy.Rank() { if o.Level.Rank() > Destroy.Rank() {
say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level) say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level)
} }
if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") {
say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID)
}
} }
if !a.Expires.After(now) { if !a.Expires.After(now) {
say("it expires before it is asked") say("it expires before it is asked")
@@ -256,6 +290,9 @@ type Warrant struct {
Channel string `json:"channel,omitempty"` Channel string `json:"channel,omitempty"`
Proofs []string `json:"proofs,omitempty"` Proofs []string `json:"proofs,omitempty"`
At time.Time `json:"at"` At time.Time `json:"at"`
// AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask
// alone, with the options it bound.
AskDigest string `json:"ask-digest,omitempty"`
// Words are why an ask ended without a choice, or what refused it. // Words are why an ask ended without a choice, or what refused it.
Words string `json:"words,omitempty"` Words string `json:"words,omitempty"`
} }
@@ -273,8 +310,9 @@ func (w Warrant) Says() string {
return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label) return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label)
} }
// For checks a warrant against the ask its asker made: the same asker and ask, a choice, an option the ask // For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the
// offered, at that option's level. An asker acts on nothing else. // same words, options and binds), a choice, an option the ask offered, at that option's level, before the
// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names.
func (w Warrant) For(asker string, a Ask) (Option, error) { func (w Warrant) For(asker string, a Ask) (Option, error) {
if w.Asker != asker || w.Ask != a.ID { if w.Asker != asker || w.Ask != a.ID {
return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID) return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID)
@@ -282,6 +320,10 @@ func (w Warrant) For(asker string, a Ask) (Option, error) {
if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who { if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who {
return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome) return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome)
} }
if d := a.Digest(); w.AskDigest != d {
return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+
"it was not the ask the person was shown", w.AskDigest, a.ID, d)
}
o, offered := a.Option(w.Option) o, offered := a.Option(w.Option)
if !offered { if !offered {
return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option) return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option)
@@ -297,6 +339,22 @@ func (w Warrant) For(asker string, a Ask) (Option, error) {
return o, nil return o, nil
} }
// Performs checks that the act an asker is about to perform is the one the chosen option bound when it
// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes.
func (o Option) Performs(act any) error {
if o.Binds == "" && o.Level == Acknowledge {
return nil
}
d, err := ActDigest(act)
if err != nil {
return err
}
if d != o.Binds {
return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d)
}
return nil
}
// Button is one answer a channel offers: its label and the router's one-time ticket for it. // Button is one answer a channel offers: its label and the router's one-time ticket for it.
type Button struct { type Button struct {
Label string `json:"label"` Label string `json:"label"`
@@ -318,6 +376,9 @@ type Message struct {
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where // To is the account linked as the operator on this kind, for a channel that verifies its sender: where
// the channel sends what is not a reply. The router's word, from its list; empty when none is linked. // the channel sends what is not a reply. The router's word, from its list; empty when none is linked.
To string `json:"to,omitempty"` To string `json:"to,omitempty"`
// Secret says the words carry something shown once (a link's code): the channel shows it and keeps no
// copy of it — no state, no history of its own.
Secret bool `json:"secret,omitempty"`
} }
// Sender is who a channel's service says sent something: the account, the name it shows, and whether the // Sender is who a channel's service says sent something: the account, the name it shows, and whether the
+1 -1
View File
@@ -1,4 +1,4 @@
# git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261008162031-55090da7e08f # git.novox.be/novox/mesh-sdk/go v0.1.8-0.20261009081503-d4077b473ea8
## explicit; go 1.22 ## explicit; go 1.22
git.novox.be/novox/mesh-sdk/go/asks git.novox.be/novox/mesh-sdk/go/asks
# github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op