From a8eda60ce186f13aa2e7de8bbd41b3b78e5ddbdc Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 9 Oct 2026 10:16:12 +0200 Subject: [PATCH] =?UTF-8?q?Bind=20each=20asked=20option=20to=20the=20exact?= =?UTF-8?q?=20act,=20and=20perform=20only=20that=20act=20on=20its=20warran?= =?UTF-8?q?t=20(hq=20ADR=200259=20=C2=A76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/mesh-controller/asker.go | 18 ++++- cmd/mesh-controller/asker_test.go | 42 +++++++++++- go.mod | 2 +- go.sum | 2 + .../novox/mesh-sdk/go/asks/asks.go | 65 ++++++++++++++++++- vendor/modules.txt | 2 +- 6 files changed, 125 insertions(+), 6 deletions(-) diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index b83e439e..0c8f977d 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -373,7 +373,11 @@ func askOf(id string, c conditions.Condition, now time.Time) (asks.Ask, map[stri approves = approves || level != asks.Acknowledge oid := optionID(act.Label) 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) if approves { @@ -382,6 +386,12 @@ func askOf(id string, c conditions.Condition, now time.Time) (asks.Ask, map[stri 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 { var b [8]byte _, _ = rand.Read(b[:]) @@ -469,6 +479,12 @@ func (a *asker) Decided(ctx context.Context, body []byte) error { return nil } 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 open, err := a.open(ctx) if err != nil { diff --git a/cmd/mesh-controller/asker_test.go b/cmd/mesh-controller/asker_test.go index 1bc43ae1..0edcb308 100644 --- a/cmd/mesh-controller/asker_test.go +++ b/cmd/mesh-controller/asker_test.go @@ -188,7 +188,8 @@ func (r *askerRig) warrantFor(t *testing.T, condition, label string) asks.Warran if o.Label == label { 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, - 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" }, "another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge }, "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) { r := newAskerRig(t) @@ -474,3 +477,40 @@ func TestAWarrantIsActedOnlyForAnOpenAskItClaimsBeforeItExpired(t *testing.T) { 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) + } + } +} diff --git a/go.mod b/go.mod index 5eaafc6b..af4c63ee 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-controller go 1.26.0 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/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 diff --git a/go.sum b/go.sum index 4c87bdf9..46fc965e 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,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.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.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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go index 491d2ba2..ca2e8006 100644 --- a/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go +++ b/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go @@ -6,6 +6,9 @@ package asks import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "regexp" @@ -96,6 +99,34 @@ type Option struct { Label string `json:"label"` Does string `json:"does"` 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:". +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). @@ -191,6 +222,9 @@ func (a Ask) Check(now time.Time) error { 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) } + 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) { say("it expires before it is asked") @@ -256,6 +290,9 @@ type Warrant struct { Channel string `json:"channel,omitempty"` Proofs []string `json:"proofs,omitempty"` 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 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) } -// For checks a warrant against the ask its asker made: the same asker and ask, a choice, an option the ask -// offered, at that option's level. An asker acts on nothing else. +// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the +// 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) { 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) @@ -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 { 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) if !offered { 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 } +// 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. type Button struct { 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 // 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"` + // 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 diff --git a/vendor/modules.txt b/vendor/modules.txt index 365367ae..f7f6eb66 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -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 git.novox.be/novox/mesh-sdk/go/asks # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op