Merge pull request 'A trusted setting is proposed through the settings verb and set only on the operator's warrant (hq ADR 0277)' (#199) from feat/0277-settings-propose into main

This commit was merged in pull request #199.
This commit is contained in:
2026-10-10 12:59:13 +00:00
10 changed files with 1805 additions and 44 deletions
+50 -6
View File
@@ -114,8 +114,14 @@ type asked struct {
// Rehearsal is an ask started at the controller's terminal (rehearse.go): about no condition, its answers
// perform nothing, and the reconciling of conditions leaves it alone.
Rehearsal bool `json:"rehearsal,omitempty"`
// Proposal is a settings layer proposed through a verb (proposals.go, novox/hq ADR 0277): about no
// condition, set on Approve by the serving controller itself, and left alone by the reconciling of conditions.
Proposal *settingsProposal `json:"proposal,omitempty"`
}
// ofACondition says an ask is one of a condition's: not a rehearsal, not a proposal.
func (r asked) ofACondition() bool { return !r.Rehearsal && r.Proposal == nil }
// partKey is an ask's place among what is asked: its condition and its part.
func partKey(condition, part string) string { return condition + "#" + part }
@@ -184,6 +190,8 @@ type asker struct {
publish func(ctx context.Context, subject string, body []byte, id string) error
// call performs an action's verb with its arguments, as the controller.
call func(ctx context.Context, a conditions.Action, args map[string]string) error
// setLayer sets a proposed settings layer on the operator's warrant (novox/hq ADR 0277); nil cannot.
setLayer setOnWarrant
// record writes the hand-act log.
record func(ctx context.Context, act link.HandAct) error
// routerRecord reads the router's record of an ask for a warrant missed; nil reads nothing.
@@ -324,17 +332,45 @@ func (a *asker) reconcile(ctx context.Context) error {
return err
}
byCondition := map[string]asked{} // by partKey
// What is open and not a condition's — a rehearsal, a proposal — still counts toward what the router holds
// open for the controller (askMostOpen); expired unanswered, it is kept so, as the router says it too.
otherOpen := 0
for _, r := range all {
if r.State == askOpen && !r.Rehearsal {
if r.State == askOpen && !r.ofACondition() && !now.Before(r.Ask.Expires) {
if _, err := a.store.Change(ctx, r.ID, func(x *asked) bool {
if x.State != askOpen || x.Acted != "" {
return false
}
x.State, x.Ended, x.Acted = string(asks.OutcomeExpired), now, "nothing: the ask expired unanswered"
return true
}); err != nil {
return err
}
continue
}
if r.State == askOpen && !r.ofACondition() {
otherOpen++
}
if r.State == askOpen && r.ofACondition() {
k := partKey(r.Condition, r.Part)
if prior, held := byCondition[k]; !held || r.Opened.After(prior.Opened) {
byCondition[k] = r
}
}
}
// A warrant missed while away, read from the router's record.
// A warrant missed while away, read from the router's record: for a condition's ask, and for a proposal's or a
// rehearsal's alike.
if a.routerRecord != nil {
var lookedUp []asked
for _, r := range byCondition {
lookedUp = append(lookedUp, r)
}
for _, r := range all {
if r.State == askOpen && !r.ofACondition() {
lookedUp = append(lookedUp, r)
}
}
for _, r := range lookedUp {
if now.Sub(r.Opened) < askCatchUpAfter {
continue
}
@@ -350,7 +386,7 @@ func (a *asker) reconcile(ctx context.Context) error {
}
byCondition = map[string]asked{}
for _, r := range all {
if r.State == askOpen && !r.Rehearsal {
if r.State == askOpen && r.ofACondition() {
byCondition[partKey(r.Condition, r.Part)] = r
}
}
@@ -409,7 +445,7 @@ func (a *asker) reconcile(ctx context.Context) error {
}
return open[i].Key < open[j].Key
})
openNow := 0
openNow := otherOpen
for _, c := range open {
if !wants(c, now) {
continue
@@ -773,7 +809,7 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
if err != nil {
return err
}
stillOpen := r.Rehearsal // a rehearsal is about no condition
stillOpen := r.Rehearsal || r.Proposal != nil // a rehearsal and a proposal are about no condition
for _, c := range open {
stillOpen = stillOpen || c.Key == r.Condition
}
@@ -820,15 +856,23 @@ func (a *asker) Decided(ctx context.Context, body []byte) error {
args["why"] = why
}
var acted error
outcome := "done"
switch {
case r.Rehearsal && act.Verb == rehearsalVerb:
// A rehearsal's answer performs nothing: it is recorded below as the operator's decision.
case r.Proposal != nil && act.Verb == proposalVerb:
// A proposed settings layer, set by this controller itself on Approve (novox/hq ADR 0277): the act's
// digest of the values is held to the record's own values before anything is set.
outcome, acted = a.decideProposal(ctx, *r, act, w)
if acted == nil && outcome != "" && !strings.HasPrefix(outcome, "nothing") {
outcome = "done: " + outcome
}
case act.Arguments["silence"] != "":
acted = a.silence(ctx, act.Arguments["silence"], conditions.MaxSilence, byWords(w), why)
default:
acted = a.call(ctx, act, args)
}
ended, outcome := a.now(), "done"
ended := a.now()
if acted != nil {
outcome = "failed: " + acted.Error()
}
+2
View File
@@ -314,6 +314,8 @@ func startAsking(ctx context.Context, open *stores, server *link.Server, conn *n
return err
},
call: callAction(conn),
// A proposed settings layer is set by this controller itself on the warrant (novox/hq ADR 0277).
setLayer: setLayerIn(open),
record: func(ctx context.Context, act link.HandAct) error {
_, err := link.RecordHandAct(ctx, conn, act)
return err
+72 -17
View File
@@ -10,10 +10,12 @@ import (
"os"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/overlay"
)
@@ -397,8 +399,9 @@ func assignCommand(ctx context.Context, verb string, args []string) error {
func settingsCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return errors.New("settings show <module> [--node <node>] [--history], settings set <module> <file> " +
"[--node <node>] [--replace], settings clear <module> [--node <node>], or settings preferences " +
"[<module>] [--node <node>]")
"[--node <node>] [--replace], settings clear <module> [--node <node>], settings preferences " +
"[<module>] [--node <node>], settings propose <module> <file | --clear> [--node <node>] [--replace], " +
"or settings proposals [<id>]")
}
open, err := openStores(ctx)
if err != nil {
@@ -412,12 +415,38 @@ func settingsCommand(ctx context.Context, args []string) error {
// **What a set removes is refused unless meant** (novox/hq ADR 0217). A layer is replaced whole,
// and on 2026-10-05 setting one placement dropped a machine's whole layer for a module without a
// word (novox/hq issue 304). Adding and changing keys needs nothing; removing one needs this.
replace := set.Bool("replace", false, "for set: remove the keys the new layer does not name")
replace := set.Bool("replace", false, "for set and propose: remove the keys the new layer does not name")
history := set.Bool("history", false, "for show: the layers this one replaced, the latest first")
clear := set.Bool("clear", false, "for propose: propose that the layer be removed")
positionals, err := parseAround(set, args[1:])
if err != nil {
return err
}
switch args[0] {
case "propose":
// A trusted setting, proposed by anyone and set only on the operator's warrant (novox/hq ADR 0277):
// the stores are opened there, so the proposal and the verb's refusals below never meet.
if len(positionals) < 1 || len(positionals) > 2 {
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
}
values := ""
if len(positionals) == 2 {
values = positionals[1]
}
return proposeCommand(ctx, positionals[0], *node, values, *clear, *replace)
case "proposals":
if len(positionals) > 1 || *node != "" || *clear || *replace || *history {
return errors.New("settings proposals [<id>]")
}
id := ""
if len(positionals) == 1 {
id = positionals[0]
}
return proposalsCommand(ctx, id)
}
if *clear {
return errors.New("--clear is for settings propose; a layer is cleared with settings clear")
}
where := "the whole mesh"
if *node != "" {
@@ -455,7 +484,7 @@ func settingsCommand(ctx context.Context, args []string) error {
"removal is meant (novox/hq ADR 0217). Nothing was changed",
positionals[0], where, strings.Join(removed, ", "), positionals[0], nodeFlag(*node))
}
if err := inv.SetSettings(ctx, *node, positionals[0], values); err != nil {
if err := inv.SetSettingsBy(ctx, *node, positionals[0], values, setByWords()); err != nil {
return err
}
fmt.Printf("%s on %s:\n", positionals[0], where)
@@ -496,8 +525,12 @@ func settingsCommand(ctx context.Context, args []string) error {
}
for _, p := range past {
shown, _ := json.MarshalIndent(p.Values, " ", " ")
fmt.Printf("%s on %s, until %s (%s):\n %s\n", positionals[0], where,
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, shown)
by := ""
if p.SetBy != "" {
by = "; " + p.SetBy
}
fmt.Printf("%s on %s, until %s (%s%s):\n %s\n", positionals[0], where,
p.ReplacedAt.Local().Format("2006-01-02 15:04:05"), p.ReplacedBy, by, shown)
}
return nil
}
@@ -513,6 +546,14 @@ func settingsCommand(ctx context.Context, args []string) error {
return err
}
fmt.Println(string(shown))
// And who set it (novox/hq ADR 0277): a layer the operator approved on their phone says so.
if setBy, setAt, has, err := inv.LayerOrigin(ctx, *node, positionals[0]); err != nil {
return err
} else if has && setBy != "" {
fmt.Printf(" %s\n", setBy)
} else if has {
fmt.Printf(" set at %s; who set it was not kept\n", setAt.Local().Format("2006-01-02 15:04"))
}
}
// Every value the module gives a default or a layer sets, and where it came from (novox/hq
// ADR 0262): the default, the mesh's layer, or this node's. Said after the layer, which stays
@@ -606,17 +647,26 @@ func settingsCommand(ctx context.Context, args []string) error {
if err := refuseTerminalSettingsThroughAVerb(ctx, inv, before, nil, positionals[0], where); err != nil {
return err
}
if err := inv.ClearSettings(ctx, *node, positionals[0]); err != nil {
if err := inv.ClearSettingsBy(ctx, *node, positionals[0], setByWords()); err != nil {
return err
}
fmt.Printf("%s on %s is back to what the module says\n", positionals[0], where)
return nil
default:
return fmt.Errorf("settings has no %q; it has show, set, clear and preferences", args[0])
return fmt.Errorf("settings has no %q; it has show, set, clear, preferences, propose and proposals", args[0])
}
}
// setByWords is who sets a layer from this process, as the layer keeps it (novox/hq ADR 0277): the caller at the
// controller's terminal, or through which verb.
func setByWords() string {
if verb, through := throughAVerb(); through {
return "set by " + link.Caller() + " through " + verb + " at " + time.Now().Local().Format("2006-01-02 15:04")
}
return "set at the controller's terminal by " + link.Caller() + " at " + time.Now().Local().Format("2006-01-02 15:04")
}
// describeEffective says each setting's value on a machine or the whole mesh, where it came from, and
// the module's default when a layer overrides it.
func describeEffective(module, where string, values []catalogue.SettingSource) string {
@@ -1107,10 +1157,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
}
// A trusted mergeable file takes any key, so its module's whole layer is the terminal's (novox/hq issue 340).
if files := catalogue.TrustedMergeable(shelf[module]); len(files) > 0 && !sameLayer(before, after) {
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
"line came through %q): %s merges whatever key a layer sets into a file root or a consumer trusts, so "+
"any key could point the module at a listener of the caller's, and whoever may call a verb includes "+
"agents (novox/hq issue 340; a file nothing trusts says \"trusted\": false). Nothing was changed",
return fmt.Errorf("the settings of %s on %s are set at the controller's terminal (`mesh-cli` on the control-node) or on "+
"the operator's warrant, never through a verb alone (this line came through %q): %s merges whatever key a "+
"layer sets into a file root or a consumer trusts, so any key could point the module at a listener of the "+
"caller's, and whoever may call a verb includes agents (novox/hq issue 340; a file nothing trusts says "+
"\"trusted\": false). Propose it instead: the settings verb with propose puts the exact values to the "+
"operator on a channel that proves who answers, and the layer is set on their Approve (novox/hq ADR 0277). "+
"Nothing was changed",
module, where, verb, strings.Join(files, ", "))
}
for _, key := range catalogue.TerminalKeys(shelf[module]) {
@@ -1119,11 +1172,13 @@ func refuseTerminalSettingsThroughAVerb(ctx context.Context, inv *inventory.Inve
if string(was) == string(now) {
continue
}
return fmt.Errorf("%s of %s on %s is set at the controller's terminal only (`mesh-cli` on the control-node), never through a verb (this "+
"line came through %q): it says where root creates and owns a module's directories, which of "+
"the machine's paths are mounted into its container, what the mesh's consumers trust, or what a file "+
"root or a person's session obeys takes, and whoever may call a verb includes agents (novox/hq issue 339; "+
"issue 340 for a mergeable file's own keys). Nothing was changed", key, module, where, verb)
return fmt.Errorf("%s of %s on %s is set at the controller's terminal (`mesh-cli` on the control-node) or on the "+
"operator's warrant, never through a verb alone (this line came through %q): it says where root creates and "+
"owns a module's directories, which of the machine's paths are mounted into its container, what the mesh's "+
"consumers trust, or what a file root or a person's session obeys takes, and whoever may call a verb "+
"includes agents (novox/hq issue 339; issue 340 for a mergeable file's own keys). Propose it instead: the "+
"settings verb with propose puts the exact values to the operator on a channel that proves who answers, and "+
"the layer is set on their Approve (novox/hq ADR 0277). Nothing was changed", key, module, where, verb)
}
return nil
}
+793
View File
@@ -0,0 +1,793 @@
package main
// A trusted setting proposed through a verb and set only on the operator's warrant (novox/hq ADR 0277).
//
// mesh-controller settings propose <module> <values.json | {…}> [--node <node>] [--replace]
// mesh-controller settings propose <module> --clear [--node <node>]
// mesh-controller settings proposals [<id>]
//
// Whoever the bus admits may PROPOSE a settings layer — the keys issue 339 made the terminal's (`places`,
// `accesses`, what a provider serves, what a trusted file asks for) among them. A proposal changes nothing: it is
// kept in the controller's own asks (broker.AskedBucket, written by the controller alone) and asked of the
// operator through the operator channel as an ask whose two answers, Approve and Decline, are both at the level
// approve, so only a channel that proves who answered (Telegram, today) carries either. The serving controller
// acts on the warrant as on any other of its asks (asker.Decided): once, for the ask it holds, the option it
// offered, and only when the act about to be performed — the module, the machine, the digest of the exact values,
// the layer they replace, whether a removal is meant — is the one the option bound when the operator was shown
// it. Then it sets the layer as `settings set` at the terminal does, with the same judgement, keeps who approved
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a
// separate act, as it is for a layer set at the terminal.
//
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
// router's content rule, outward.Check) is shown with that part replaced by ‹address›, ‹path› or ‹withheld›, the
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
// waiting for an answer that cannot come.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/nats-io/nats.go"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/outward"
)
// settingsProposal is one proposed change to a module's settings layer, as the controller's ask keeps it
// (asked.Proposal). Every field the ask is composed from is here, so the serving controller composes the same ask
// the proposer did, and the warrant's digest holds to it.
type settingsProposal struct {
Module string `json:"module"`
// Node is the machine, or empty for the whole mesh.
Node string `json:"node,omitempty"`
// Values is the layer proposed, whole; Clear says the layer is removed instead.
Values map[string]any `json:"values,omitempty"`
Clear bool `json:"clear,omitempty"`
// Replace says the keys the layer had and Values do not name are meant to go (novox/hq ADR 0217).
Replace bool `json:"replace,omitempty"`
// Before is the layer as it stood when the proposal was made, and HadLayer whether there was one: what the
// operator was shown the change against, and what must still stand when the warrant is acted on.
Before map[string]any `json:"before,omitempty"`
HadLayer bool `json:"had-layer"`
// From is who proposed it, as the bus named the caller; At is when.
From string `json:"from"`
At time.Time `json:"at"`
// Digest is the digest of Values (layerDigest), BeforeDigest of Before.
Digest string `json:"digest"`
BeforeDigest string `json:"before-digest"`
}
// proposalVerb is the verb a proposal's answers bind: the controller's own settings, performed by itself.
const proposalVerb = askerName + ".settings"
// The two answers, both at the level approve.
const (
answerApprove = "approve"
answerDecline = "decline"
)
// layerDigest is the digest a proposal binds: SHA-256 over the layer's canonical JSON (Go sorts a map's keys), an
// absent layer and an empty one alike.
func layerDigest(values map[string]any) string {
if values == nil {
values = map[string]any{}
}
raw, _ := json.Marshal(values)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:])
}
// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four, so no
// word of it is long enough for the router to take for a secret.
func fingerprint(digest string) string {
hexed := strings.TrimPrefix(digest, "sha256:")
if len(hexed) < 24 {
return hexed
}
var groups []string
for i := 0; i < 24; i += 4 {
groups = append(groups, hexed[i:i+4])
}
return strings.Join(groups, " ")
}
// where is the layer in words: "shanks" or "the whole mesh".
func (p settingsProposal) where() string {
if p.Node == "" {
return "the whole mesh"
}
return p.Node
}
// about is what the ask is about, a key without spaces: the module's layer on the machine, or on the mesh.
func (p settingsProposal) about() string {
if p.Node == "" {
return "settings." + p.Module + ".mesh"
}
return "settings." + p.Module + "." + p.Node
}
// actions are the proposal's two answers as the controller keeps them (asked.Actions): each binds the exact act
// through boundAct — the verb, the machine, the level and every argument, the values' digest among them.
func (p settingsProposal) actions(id string) []conditions.Action {
args := func(answer string) map[string]string {
return map[string]string{"proposal": id, "answer": answer, "module": p.Module, "node": p.Node,
"values": p.Digest, "before": p.BeforeDigest, "had-layer": strconv.FormatBool(p.HadLayer),
"replace": strconv.FormatBool(p.Replace), "clear": strconv.FormatBool(p.Clear), "from": p.From,
"at": p.At.UTC().Format(time.RFC3339Nano)}
}
return []conditions.Action{
{Label: "Approve", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerApprove)},
{Label: "Decline", Verb: proposalVerb, Machine: p.Node, Level: conditions.LevelApprove, Arguments: args(answerDecline)},
}
}
// ask is the proposal's ask, composed from it alone, as the router is sent it: the headline, the explanation
// with the change shown under the content rule, and the two options with their bound acts.
func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[string]int) {
verb := "Set"
if p.Clear {
verb = "Clear"
}
headline := fmt.Sprintf("%s %s on %s?", verb, p.Module, p.where())
if len([]rune(headline)) > asks.HeadlineLength {
headline = fmt.Sprintf("%s settings on %s?", verb, p.where())
}
if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " settings?"
}
shown, whole := p.change(machines)
var b strings.Builder
if p.Clear {
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where())
} else {
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where())
}
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"))
switch {
case p.Clear && p.HadLayer:
b.WriteString("The layer it removes:\n\n")
case p.Clear:
b.WriteString("There is no layer to remove; approving changes nothing.")
case !p.HadLayer:
b.WriteString("There is no layer yet; this is the whole of it:\n\n")
default:
b.WriteString("The layer is replaced whole; what changes against it:\n\n")
}
b.WriteString(shown)
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest))
if !whole {
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
"mesh-cli settings proposals " + id + ".")
}
if p.Clear {
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
} else {
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
}
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
About: p.about()}
options := map[string]int{}
for i, act := range p.actions(id) {
binds, _ := asks.ActDigest(boundAct(act))
oid := optionID(act.Label)
options[oid] = i
does := "the settings are set; nothing is pushed yet"
if p.Clear {
does = "the layer is removed; nothing is pushed yet"
}
if act.Arguments["answer"] == answerDecline {
does = "nothing changes; the proposal is discarded"
}
q.Options = append(q.Options, asks.Option{ID: oid, Label: act.Label, Does: does, Level: asks.Level(act.Level),
Binds: binds})
}
return q, options
}
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`.
const shownMost = 1400
// change is what changes, line by line, each value shown under the content rule, and whether every value was
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the
// count of keys unchanged.
func (p settingsProposal) change(machines []string) (string, bool) {
whole := true
say := func(v any) string {
s, w := sayableValue(v, machines)
whole = whole && w
return s
}
var lines []string
if p.Clear {
was := leaves(p.Before, "")
for _, k := range layerKeys(was) {
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k])))
}
} else {
added, changed, removed := settingsChange(p.Before, p.Values)
was, now := leaves(p.Before, ""), leaves(p.Values, "")
for _, k := range added {
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k])))
}
for _, k := range changed {
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k])))
}
for _, k := range removed {
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k])))
}
unchanged := len(now) - len(added) - len(changed)
switch {
case len(lines) == 0 && unchanged > 0:
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
case unchanged > 0:
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
}
}
out := strings.Join(lines, "\n")
if len(out) > shownMost {
cut := shownMost
for cut > 0 && out[cut] != '\n' {
cut--
}
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut)
whole = false
}
return out, whole
}
func layerKeys(m map[string]any) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// The shapes a value is shown without, in place: an address with what follows it up to a separator, and a
// path. Replaced in place rather than word by word, so "recalbox=smb://host/share@/mnt/recalbox" is shown as
// "recalbox=‹address›@‹path›" and keeps its shape.
var (
shownURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://[^\s@"',;)\]}]*`)
shownIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`)
shownEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
shownPath = regexp.MustCompile(`(^|[\s=@,;:"'(\[{])((?:~|\.{1,2})?/[^\s"',;:)\]}]*)`)
)
// Markers for what is not shown.
const (
markAddress = "‹address›"
markPath = "‹path›"
markWithheld = "‹withheld›"
)
// sayableValue is a value as the phone is shown it, and whether it was shown whole. A string is shown bare; any
// other value as JSON.
func sayableValue(v any, machines []string) (string, bool) {
text, ok := v.(string)
if !ok {
raw, err := json.Marshal(v)
if err != nil {
return markWithheld, false
}
text = string(raw)
}
if text == "" {
return `""`, true
}
out := sayable(text, machines)
return out, out == text
}
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
// pass is withheld whole.
func sayable(text string, machines []string) string {
if _, ok := outward.Check(text, machines...); ok {
return text
}
out := shownURL.ReplaceAllString(text, markAddress)
out = shownEmail.ReplaceAllString(out, markAddress)
out = shownIPv4.ReplaceAllString(out, markAddress)
out = shownPath.ReplaceAllString(out, "${1}"+markPath)
// Then word by word, as the router reads them: a host name, a path the shapes above missed, a secret's shape.
words := strings.FieldsFunc(out, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || strings.ContainsRune("\"'`()[]{}<>,;|", r)
})
for _, w := range words {
if refusal, ok := outward.Check(w, machines...); !ok {
mark := markWithheld
switch refusal.Class {
case "address":
mark = markAddress
case "path":
mark = markPath
}
out = strings.ReplaceAll(out, w, mark)
}
}
if _, ok := outward.Check(out, machines...); ok {
return out
}
out = strings.ReplaceAll(outward.Scrub(out, markWithheld, machines...), "(withheld)", markWithheld)
if _, ok := outward.Check(out, machines...); ok {
return out
}
return markWithheld
}
// ---- proposing ---------------------------------------------------------------------------------------
// proposer is the propose command's reaches, given so a test needs no store and no bus.
type proposer struct {
// layer reads a module's layer as it stands.
layer func(ctx context.Context, node, module string) (map[string]any, bool, error)
// judge judges the layer as SetSettings would, keeping nothing.
judge func(ctx context.Context, node, module string, values map[string]any) error
// machines are the mesh's machine names: allowed in the ask's words.
machines func(ctx context.Context) ([]string, error)
store askedStore
publish func(ctx context.Context, subject string, body []byte, id string) error
// routerHere and grantHeld are the asker's own judgements of whether an ask can be carried; nil is yes.
routerHere func(ctx context.Context) (bool, error)
grantHeld func(ctx context.Context) (bool, string, error)
// routerRecord reads the router's record of an ask: its state, or "" for none.
routerRecord func(ctx context.Context, id string) (string, error)
now func() time.Time
caller string
// waitFor and waitEvery bound how long propose waits for the router's word on the new ask.
waitFor time.Duration
waitEvery time.Duration
}
// proposeInput is what is proposed.
type proposeInput struct {
module string
node string
values map[string]any
clear bool
replace bool
}
// atTheTerminalInstead names the other way, said whenever a proposal is refused for want of a channel.
func atTheTerminalInstead(in proposeInput) string {
if in.clear {
return "the operator may clear it at the controller's terminal instead: mesh-cli settings clear " + in.module + nodeFlag(in.node)
}
return "the operator may set it at the controller's terminal instead: mesh-cli settings set " + in.module + " '{…}'" + nodeFlag(in.node)
}
// propose keeps a proposal in the controller's asks and asks the operator; it answers the words said to the
// caller. Nothing is set here.
func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error) {
refuse := func(format string, args ...any) (string, error) {
return "", fmt.Errorf(format+". Nothing was proposed", args...)
}
if in.module == "" {
return refuse("a proposal names a module")
}
if !in.clear && in.values == nil {
return refuse("a proposal gives the values, or says --clear")
}
now := pr.now()
before, had, err := pr.layer(ctx, in.node, in.module)
if err != nil {
return "", err
}
p := settingsProposal{Module: in.module, Node: in.node, Values: in.values, Clear: in.clear, Replace: in.replace,
Before: before, HadLayer: had, From: pr.caller, At: now, BeforeDigest: layerDigest(before)}
if in.clear {
// A clear binds the layer it removes: its digest is the fingerprint the operator reads.
p.Values, p.Digest = nil, layerDigest(before)
} else {
// Judged now, as SetSettings judges, so the operator is never asked about a layer the mesh would refuse —
// and judged again when the warrant is acted on.
if err := pr.judge(ctx, in.node, in.module, in.values); err != nil {
return refuse("%v", err)
}
_, _, removed := settingsChange(before, in.values)
if len(removed) > 0 && !in.replace {
return refuse("%s on %s: this layer would no longer set %s. A layer is replaced whole; read it with "+
"`settings show %s%s` and include what should stay, or add --replace if the removal is meant "+
"(novox/hq ADR 0217)", in.module, p.where(), strings.Join(removed, ", "), in.module, nodeFlag(in.node))
}
p.Digest = layerDigest(in.values)
}
var machines []string
if pr.machines != nil {
if machines, err = pr.machines(ctx); err != nil {
return "", err
}
}
id := newProposalID()
q, options := p.ask(id, machines)
if err := q.Check(now); err != nil {
return refuse("%v", err)
}
words := []string{q.Headline, q.Explanation, q.OnExpiry}
for _, o := range q.Options {
words = append(words, o.Label, o.Does)
}
if refusal, ok := outward.Check(strings.Join(words, "\n"), machines...); !ok {
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
"shown without it; %s", refusal, atTheTerminalInstead(in))
}
// Fail closed, before anything is kept: no router, no grant, no channel means no ask.
if pr.routerHere != nil {
here, err := pr.routerHere(ctx)
if err != nil {
return "", err
}
if !here {
return refuse("no router takes the controller's asks (no module holding the operator channel is assigned), "+
"so the operator cannot be asked; %s", atTheTerminalInstead(in))
}
}
if pr.grantHeld != nil {
held, why, err := pr.grantHeld(ctx)
if err != nil {
return "", err
}
if !held {
return refuse("the operator cannot be asked yet: %s; %s", why, atTheTerminalInstead(in))
}
}
all, err := pr.store.All(ctx)
if err != nil {
return "", err
}
open := 0
for _, r := range all {
if r.State != askOpen || !now.Before(r.Ask.Expires) {
continue
}
if r.Proposal != nil && r.Proposal.Module == p.Module && r.Proposal.Node == p.Node && r.Proposal.Digest == p.Digest &&
r.Proposal.Clear == p.Clear {
return refuse("the same change is already proposed as %s and waits for the operator's answer until %s; "+
"`settings proposals` lists it", r.ID, r.Ask.Expires.Local().Format("15:04"))
}
open++
}
if open >= askMostOpen {
return refuse("%d questions already wait for the operator's answer, and the operator is asked at most %d at "+
"once; `settings proposals` lists the proposals among them", open, askMostOpen)
}
// Kept before it is published, as the asker keeps every ask, so a warrant always finds it.
if err := pr.store.Create(ctx, asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options,
State: askOpen, Opened: now, Proposal: &p}); err != nil {
return "", fmt.Errorf("the proposal could not be kept in the controller's asks, so the operator was not asked: %w", err)
}
body, err := json.Marshal(q)
if err != nil {
return "", err
}
if err := pr.publish(ctx, asks.AskSubject(askerName), body, "ask."+id); err != nil {
_, _ = pr.store.Change(ctx, id, func(x *asked) bool {
if x.State != askOpen || x.Acted != "" {
return false
}
x.State, x.Ended, x.Acted = askUnsent, pr.now(), "nothing: it could not be published: "+err.Error()
return true
})
return "", fmt.Errorf("the operator could not be asked (%v); the proposal %s is kept and will never become "+
"active. Propose it again, or %s", err, id, atTheTerminalInstead(in))
}
// The router's word, before answering: it refuses at once an ask no channel can carry (the serving controller
// hears that and ends the ask here), and records one it took.
taken := "the router has not said yet whether it took the ask; `settings proposals` shows where it stands"
for deadline := time.Now().Add(pr.waitFor); time.Now().Before(deadline); {
if r, err := pr.store.Get(ctx, id); err == nil && r != nil && r.State != askOpen {
why := r.Acted
if r.Warrant != nil && r.Warrant.Words != "" {
why = r.Warrant.Words
}
return "", fmt.Errorf("the router did not ask the operator: the ask %s %s (%s). Nothing changes; %s",
id, r.State, why, atTheTerminalInstead(in))
}
if pr.routerRecord != nil {
if state, err := pr.routerRecord(ctx, id); err == nil && state != "" {
taken = "the router took the ask and shows it on the channels that can carry it"
break
}
}
time.Sleep(pr.waitEvery)
}
var b strings.Builder
fmt.Fprintf(&b, "proposal %s: %s\n", id, q.Headline)
fmt.Fprintf(&b, " %s\n", taken)
fmt.Fprintf(&b, " the operator is asked on a channel that proves who answers, and reads the change with fingerprint %s\n",
fingerprint(p.Digest))
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
q.Expires.Local().Format("2006-01-02 15:04"))
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node))
return b.String(), nil
}
func pushWord(node string) string {
if node == "" {
return "--behind"
}
return node
}
func newProposalID() string {
return "s" + strings.TrimPrefix(newAskID(), "c")
}
// How long propose waits for the router's word on a new ask, and how often it looks.
const (
routerAnswersWithin = 8 * time.Second
routerAnswersEvery = 250 * time.Millisecond
)
// proposeCommand is `settings propose`, on this controller's stores and bus.
func proposeCommand(ctx context.Context, module, node, valuesArg string, clear, replace bool) error {
var values map[string]any
if !clear {
if valuesArg == "" {
return errors.New("settings propose <module> <settings.json | {…}> [--node <node>] [--replace], or settings propose <module> --clear [--node <node>]")
}
var raw []byte
var err error
if strings.HasPrefix(strings.TrimSpace(valuesArg), "{") {
raw = []byte(valuesArg)
} else if raw, err = os.ReadFile(valuesArg); err != nil {
return err
}
if err := json.Unmarshal(raw, &values); err != nil {
return fmt.Errorf("%s is not a settings file: %w", valuesArg, err)
}
} else if valuesArg != "" {
return errors.New("settings propose: --clear takes no values")
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
js, err := aBus()
if err != nil {
return err
}
defer js.Close()
conn := js.Conn()
pr := proposer{
layer: open.inventory.Layer,
judge: open.inventory.JudgeSettings,
machines: func(ctx context.Context) ([]string, error) {
nodes, err := open.inventory.Nodes(ctx)
if err != nil {
return nil, err
}
var names []string
for _, n := range nodes {
names = append(names, n.Name)
}
return names, nil
},
store: busAsked{conn: conn},
publish: func(ctx context.Context, subject string, body []byte, id string) error {
_, err := js.Context().Publish(subject, body, nats.MsgId(id), nats.Context(ctx))
return err
},
routerHere: routerHereIn(open.inventory),
grantHeld: grantHeldIn(open),
routerRecord: func(ctx context.Context, id string) (string, error) {
bucket, err := asksRecords(ctx, open.inventory)
if err != nil || bucket == "" {
return "", err
}
state, _, err := readRouterRecord(ctx, conn, bucket, askerName, id)
return state, err
},
now: time.Now,
caller: link.Caller(),
waitFor: routerAnswersWithin, waitEvery: routerAnswersEvery,
}
words, err := pr.propose(ctx, proposeInput{module: module, node: node, values: values, clear: clear, replace: replace})
if err != nil {
return err
}
fmt.Println(words)
return nil
}
// ---- listing ---------------------------------------------------------------------------------------
// proposalsCommand is `settings proposals [<id>]`: every proposal, newest first, and where each stands; with an
// id, the proposal whole — its values, the layer it was shown against, and its digest.
func proposalsCommand(ctx context.Context, id string) error {
return onTheBus(func(conn *nats.Conn) error {
all, err := busAsked{conn: conn}.All(ctx)
if err != nil {
return err
}
var proposals []asked
for _, r := range all {
if r.Proposal != nil {
proposals = append(proposals, r)
}
}
sort.Slice(proposals, func(i, j int) bool { return proposals[i].Opened.After(proposals[j].Opened) })
if id != "" {
for _, r := range proposals {
if r.ID == id {
fmt.Print(describeProposal(r, time.Now()))
return nil
}
}
return fmt.Errorf("no proposal %s is kept", id)
}
if len(proposals) == 0 {
fmt.Println("no settings have been proposed")
return nil
}
for _, r := range proposals {
fmt.Print(proposalLine(r, time.Now()))
}
return nil
})
}
// proposalState is where a proposal stands, in a word or two.
func proposalState(r asked, now time.Time) string {
switch {
case r.State == askOpen && now.Before(r.Ask.Expires):
return "waiting for the operator until " + r.Ask.Expires.Local().Format("2006-01-02 15:04")
case r.State == askOpen:
return "expired unanswered; discarded"
case r.Acted != "":
return r.State + ": " + r.Acted
}
return r.State
}
func proposalLine(r asked, now time.Time) string {
p := *r.Proposal
what := "set"
if p.Clear {
what = "clear"
}
keys := strings.Join(layerKeys(p.Values), ", ")
if p.Clear {
keys = "the whole layer"
}
return fmt.Sprintf("%s %s %s on %s (%s)\n by %s at %s; fingerprint %s\n %s\n", r.ID, what, p.Module, p.where(),
keys, p.From, p.At.Local().Format("2006-01-02 15:04"), fingerprint(p.Digest), proposalState(r, now))
}
func describeProposal(r asked, now time.Time) string {
p := *r.Proposal
var b strings.Builder
b.WriteString(proposalLine(r, now))
fmt.Fprintf(&b, " digest %s; the layer it was shown against %s\n", p.Digest, p.BeforeDigest)
shownValues, _ := json.MarshalIndent(p.Values, " ", " ")
if p.Clear {
fmt.Fprintf(&b, " clears the layer\n")
} else {
fmt.Fprintf(&b, " values:\n %s\n", shownValues)
}
if p.HadLayer {
shownBefore, _ := json.MarshalIndent(p.Before, " ", " ")
fmt.Fprintf(&b, " the layer as it stood:\n %s\n", shownBefore)
} else {
b.WriteString(" there was no layer\n")
}
if r.Warrant != nil && r.Warrant.By != nil {
fmt.Fprintf(&b, " answered: %s, through %s, at %s\n", r.Warrant.Says(), viaWords(*r.Warrant),
r.Warrant.At.Local().Format("2006-01-02 15:04"))
}
return b.String()
}
// ---- acting on the warrant --------------------------------------------------------------------------
// setOnWarrant performs an approved proposal: the layer set or cleared as `settings set` and `settings clear` at the
// terminal do, with who approved it kept beside the layer. It answers the words of what changed.
type setOnWarrant func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error)
// decideProposal is what the asker does with a warrant for a proposal (asker.Decided): nothing on Decline, and on
// Approve the act only when it is the one the option bound — the digest of the exact values kept here is the one
// in the act, and so the one the ask's option bound and the warrant's ask digest covers.
func (a *asker) decideProposal(ctx context.Context, r asked, act conditions.Action, w asks.Warrant) (string, error) {
p := *r.Proposal
if act.Arguments["answer"] != answerApprove {
return "nothing: the operator declined; the layer is unchanged", nil
}
if a.setLayer == nil {
return "", errors.New("this controller cannot set a layer on a warrant")
}
// The record's own proposal against the act the option bound: the act is composed again from the record —
// its module, machine, values (digested again), the layer they replace, whether a removal is meant, a clear,
// who proposed it and when — and must digest to what the option bound when the operator was shown it. A
// record changed after the ask, in any field, is refused and nothing is set.
again := p
again.Digest, again.BeforeDigest = layerDigest(p.Values), layerDigest(p.Before)
if p.Clear {
again.Digest = layerDigest(p.Before)
}
recomposed := again.actions(r.ID)
chosen := -1
for i, candidate := range recomposed {
if candidate.Arguments["answer"] == act.Arguments["answer"] {
chosen = i
}
}
option, offered := r.Ask.Option(w.Option)
if chosen < 0 || !offered {
return "", fmt.Errorf("the proposal %s offers no answer %q: nothing is set", r.ID, act.Arguments["answer"])
}
if err := option.Performs(boundAct(recomposed[chosen])); err != nil {
return "", fmt.Errorf("the proposal kept for %s is not the one the operator was shown: %v", r.ID, err)
}
setBy := fmt.Sprintf("approved by %s via %s at %s (ask %s, proposed by %s)", byWords(w), viaWords(w),
w.At.Local().Format("2006-01-02 15:04"), r.ID, p.From)
return a.setLayer(ctx, p, r.ID, setBy)
}
// setLayerIn is setOnWarrant on this controller's stores: the layer must still be the one the operator was shown
// the change against (to-be 46 §10, step 7), then it is set with the same judgement as at the terminal.
func setLayerIn(open *stores) setOnWarrant {
return func(ctx context.Context, p settingsProposal, askID, setBy string) (string, error) {
inv := open.inventory
before, had, err := inv.Layer(ctx, p.Node, p.Module)
if err != nil {
return "", err
}
if layerDigest(before) != p.BeforeDigest || had != p.HadLayer {
return "", fmt.Errorf("the layer of %s on %s changed since the operator was shown the change: it is not set; "+
"propose it again", p.Module, p.where())
}
var changed string
if p.Clear {
if err := inv.ClearSettingsBy(ctx, p.Node, p.Module, setBy); err != nil {
return "", err
}
changed = "the layer is removed"
} else {
added, altered, removed := settingsChange(before, p.Values)
if len(removed) > 0 && !p.Replace {
return "", fmt.Errorf("the layer would no longer set %s, and the removal was not meant: nothing is set",
strings.Join(removed, ", "))
}
if err := inv.SetSettingsBy(ctx, p.Node, p.Module, p.Values, setBy); err != nil {
return "", err
}
var parts []string
for _, k := range added {
parts = append(parts, "+ "+k)
}
for _, k := range altered {
parts = append(parts, "~ "+k)
}
for _, k := range removed {
parts = append(parts, "- "+k)
}
changed = strings.Join(parts, ", ")
if changed == "" {
changed = "nothing changed"
}
}
return changed + " (ask " + askID + ")", nil
}
}
+746
View File
@@ -0,0 +1,746 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/outward"
)
// novox/hq ADR 0277: a trusted setting is proposed through a verb by anyone the bus admits, asked of the
// operator at the level approve with the exact change, and set only on the operator's warrant — once, for the
// exact values the option bound; declined, expired or refused, it is discarded; nothing is asked when nothing
// can carry the ask.
// aProposer is the propose path with its reaches faked: a layer as it stands, a judge that records what it
// judged, a memory store, and what was published.
type proposerRig struct {
pr proposer
store memAskedStore
sent []published
judged []map[string]any
before map[string]any
had bool
refusedBy string
now time.Time
}
func newProposerRig(t *testing.T) *proposerRig {
r := &proposerRig{store: memAskedStore{}, now: time.Date(2026, 10, 10, 14, 5, 0, 0, time.UTC)}
r.pr = proposer{
layer: func(_ context.Context, node, module string) (map[string]any, bool, error) {
return r.before, r.had, nil
},
judge: func(_ context.Context, node, module string, values map[string]any) error {
r.judged = append(r.judged, values)
if r.refusedBy != "" {
return errors.New(r.refusedBy)
}
return nil
},
machines: func(context.Context) ([]string, error) { return []string{"anchor", "laptop", "shanks"}, nil },
store: r.store,
publish: func(_ context.Context, subject string, body []byte, id string) error {
r.sent = append(r.sent, published{subject, id, body})
return nil
},
now: func() time.Time { return r.now },
caller: "g14/claude-code, through the mesh-controller seat",
waitFor: time.Millisecond,
waitEvery: time.Millisecond,
}
return r
}
func (r *proposerRig) askSent(t *testing.T) asks.Ask {
t.Helper()
if len(r.sent) != 1 || r.sent[0].subject != asks.AskSubject("mesh-controller") {
t.Fatalf("published %+v", r.sent)
}
var q asks.Ask
if err := json.Unmarshal(r.sent[0].body, &q); err != nil {
t.Fatal(err)
}
return q
}
func (r *proposerRig) theProposal(t *testing.T) asked {
t.Helper()
for _, a := range r.store {
if a.Proposal != nil {
return a
}
}
t.Fatal("no proposal is kept")
return asked{}
}
var mountsSources = map[string]any{"sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro", "shares": "library=/mnt/library"}
// The ask: at the level approve on both answers, each binding the proposal's act, with every key and its exact new
// value as far as the content rule lets it leave the mesh, the layer it was shown against, and nothing set.
func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
r := newProposerRig(t)
r.before, r.had = map[string]any{"shares": "none", "old": "x"}, true
words, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: mountsSources, replace: true})
if err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if err := q.Check(r.now); err != nil {
t.Fatalf("the ask is refused: %v", err)
}
if q.Headline != "Set mounts on shanks?" || q.About != "settings.mounts.shanks" || q.Who != asks.Operator ||
!q.Expires.Equal(r.now.Add(askApproveFor)) {
t.Errorf("the ask: %+v", q)
}
if len(q.Options) != 2 {
t.Fatalf("options %+v", q.Options)
}
for _, o := range q.Options {
if o.Level != asks.Approve || !strings.HasPrefix(o.Binds, "sha256:") {
t.Errorf("the option %s is %s and binds %q", o.ID, o.Level, o.Binds)
}
}
if q.Options[0].Binds == q.Options[1].Binds {
t.Error("Approve and Decline bind the same act")
}
for _, line := range []string{
"Set the settings of mounts on shanks to these values?",
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".",
"+ sources: recalbox=‹address›@‹path›:ro",
"~ shares: library=‹path› (was: none)",
"- old (was: x)",
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
"read it whole, with this fingerprint",
} {
if !strings.Contains(q.Explanation, line) {
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
}
}
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
if strings.Contains(q.Explanation, leak) {
t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
}
}
all := []string{q.Headline, q.Explanation, q.OnExpiry}
for _, o := range q.Options {
all = append(all, o.Label, o.Does)
}
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would refuse the ask: %s", refusal)
}
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
kept := r.theProposal(t)
p := kept.Proposal
if kept.State != askOpen || kept.Ask.Digest() != q.Digest() || p.Module != "mounts" || p.Node != "shanks" ||
p.Digest != layerDigest(mountsSources) || p.BeforeDigest != layerDigest(r.before) || !p.Replace || !p.HadLayer ||
p.From != r.pr.caller || kept.ofACondition() {
t.Errorf("kept %+v / %+v", kept, p)
}
// Each option binds exactly the act the controller will perform (boundAct over the kept action).
for i, act := range kept.Actions {
binds, _ := asks.ActDigest(boundAct(act))
if q.Options[i].Binds != binds || act.Arguments["values"] != p.Digest || act.Arguments["before"] != p.BeforeDigest ||
act.Verb != proposalVerb || act.Level != conditions.LevelApprove {
t.Errorf("the option %s does not bind the kept act: %+v", q.Options[i].ID, act)
}
}
if len(r.judged) != 1 || r.judged[0]["sources"] != mountsSources["sources"] {
t.Errorf("judged %v", r.judged)
}
if !strings.Contains(words, "nothing changes") || !strings.Contains(words, kept.ID) {
t.Errorf("the caller is told: %s", words)
}
}
// A layer for the whole mesh is proposed too.
func TestAMeshWideLayerIsProposed(t *testing.T) {
r := newProposerRig(t)
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", values: map[string]any{"x": "1"}}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") {
t.Errorf("%+v", q)
}
}
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
r := newAskerRig(t)
calls := withSetLayer(r)
a := aProposalAsked(t, r, "s7", aProposal(r.now))
r.now = r.now.Add(askApproveFor + time.Minute)
if err := r.a.reconcile(context.Background()); err != nil {
t.Fatal(err)
}
if got := r.store["s7"]; got.State != string(asks.OutcomeExpired) || !strings.HasPrefix(got.Acted, "nothing") {
t.Errorf("kept as %+v", got)
}
answerWith(t, r, warrantOn(a, "approve", r.now.Add(-2*time.Minute)))
if len(*calls) != 0 {
t.Errorf("set after expiry: %+v", *calls)
}
}
// A clear is proposed too, and shows the layer it removes.
func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
r := newProposerRig(t)
r.before, r.had = map[string]any{"places": map[string]any{"data": map[string]any{"path": "/srv/notes", "owner": "1001:1001"}}}, true
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", clear: true}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") {
t.Errorf("%+v", q)
}
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
t.Errorf("a clear: %+v, judged %v", p, r.judged)
}
}
// What the phone is shown keeps a value's shape and passes the content rule: an address, a path, a secret's
// shape each replaced in place; a value every word of which may leave the mesh shown whole.
func TestAValueIsShownInItsShapeAndPassesTheContentRule(t *testing.T) {
for in, want := range map[any]string{
"recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro": "recalbox=‹address›@‹path›:ro",
"library=/mnt/library": "library=‹path›",
"none": "none",
"Inter 13": "Inter 13",
"10.77.0.9:53": "‹address›",
"jochen@example.com": "‹address›",
"nas.lan": "‹address›",
"anchor": "anchor",
"-----BEGIN CERTIFICATE-----": "‹withheld›",
42: "42",
true: "true",
} {
got, whole := sayableValue(in, []string{"anchor"})
if got != want {
t.Errorf("%v shown as %q, want %q", in, got, want)
}
if whole != (got == want && !strings.Contains(want, "‹")) {
t.Errorf("%v: whole %v", in, whole)
}
if _, ok := outward.Check(got, "anchor"); !ok {
t.Errorf("%v shown as %q, which the router refuses", in, got)
}
}
for _, v := range []any{[]any{"a", "/etc/x"}, map[string]any{"path": "/srv/x", "owner": "1001:1001"}} {
got, _ := sayableValue(v, nil)
if _, ok := outward.Check(got); !ok || strings.Contains(got, "/srv") || strings.Contains(got, "/etc") {
t.Errorf("%v shown as %q", v, got)
}
}
}
// A proposal that the mesh would refuse to set, or that would silently remove a key, is refused before anybody is
// asked (ADR 0217 holds for a proposal as for a set).
func TestAProposalTheMeshWouldRefuseIsNotAsked(t *testing.T) {
r := newProposerRig(t)
r.before, r.had = map[string]any{"a": 1, "b": 2}, true
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1}})
if err == nil || !strings.Contains(err.Error(), "would no longer set b") || !strings.Contains(err.Error(), "ADR 0217") {
t.Errorf("a silent removal: %v", err)
}
r.refusedBy = "refused: notes on laptop cannot compose"
_, err = r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"a": 1, "b": 3}})
if err == nil || !strings.Contains(err.Error(), "cannot compose") {
t.Errorf("a layer the mesh refuses: %v", err)
}
if len(r.sent) != 0 || len(r.store) != 0 {
t.Errorf("asked anyway: %+v %+v", r.sent, r.store)
}
}
// Fail closed: no router, no grant, a publish that fails, or the router's refusal each leave nothing waiting for
// an answer that cannot come, and name the terminal's line.
func TestAProposalFailsClosedWhenNothingCanCarryIt(t *testing.T) {
r := newProposerRig(t)
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
r.pr.routerHere = func(context.Context) (bool, error) { return false, nil }
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "no router") ||
!strings.Contains(err.Error(), "mesh-cli settings set mounts") {
t.Errorf("without a router: %v", err)
}
r.pr.routerHere = nil
r.pr.grantHeld = func(context.Context) (bool, string, error) { return false, "the bus's user list is behind", nil }
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "user list is behind") {
t.Errorf("without the grant: %v", err)
}
if len(r.sent) != 0 || len(r.store) != 0 {
t.Fatalf("asked anyway: %+v %+v", r.sent, r.store)
}
r.pr.grantHeld = nil
r.pr.publish = func(context.Context, string, []byte, string) error { return errors.New("the bus is away") }
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "the bus is away") ||
!strings.Contains(err.Error(), "never become active") {
t.Errorf("a publish that fails: %v", err)
}
if kept := r.theProposal(t); kept.State != askUnsent {
t.Errorf("an unpublished proposal is %s", kept.State)
}
// The router's refusal, heard by the serving controller and kept here, is said to the caller.
r = newProposerRig(t)
r.pr.publish = func(_ context.Context, _ string, _ []byte, id string) error {
ask := strings.TrimPrefix(id, "ask.")
r.store[ask] = func() asked {
a := r.store[ask]
a.State, a.Acted = string(asks.OutcomeRefused), "nothing: the ask refused: no channel can carry any of its answers now"
return a
}()
return nil
}
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "did not ask the operator") ||
!strings.Contains(err.Error(), "no channel can carry") {
t.Errorf("the router's refusal: %v", err)
}
}
// The bounds: at most three asks open for the controller, and the same change not proposed twice.
func TestAProposalIsBounded(t *testing.T) {
r := newProposerRig(t)
in := proposeInput{module: "mounts", node: "shanks", values: mountsSources}
if _, err := r.pr.propose(context.Background(), in); err != nil {
t.Fatal(err)
}
if _, err := r.pr.propose(context.Background(), in); err == nil || !strings.Contains(err.Error(), "already proposed") {
t.Errorf("the same change twice: %v", err)
}
for _, node := range []string{"laptop", "anchor"} {
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: node, values: mountsSources}); err != nil {
t.Fatal(err)
}
}
_, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: map[string]any{"x": 1}})
if err == nil || !strings.Contains(err.Error(), "3 questions already wait") {
t.Errorf("a fourth: %v", err)
}
if len(r.sent) != 3 {
t.Errorf("published %d", len(r.sent))
}
}
// ---- the warrant ------------------------------------------------------------------------------------
// aProposalAsked keeps a proposal's ask in the asker rig's store, as propose keeps it.
func aProposalAsked(t *testing.T, r *askerRig, id string, p settingsProposal) asked {
t.Helper()
q, options := p.ask(id, nil)
if err := q.Check(r.now); err != nil {
t.Fatal(err)
}
a := asked{ID: id, Condition: q.About, Ask: q, Actions: p.actions(id), Options: options, State: askOpen, Opened: r.now, Proposal: &p}
r.store[id] = a
return a
}
func aProposal(now time.Time) settingsProposal {
before := map[string]any{"shares": "none"}
return settingsProposal{Module: "mounts", Node: "shanks", Values: mountsSources, Replace: true, Before: before, HadLayer: true,
From: "g14/claude-code", At: now, Digest: layerDigest(mountsSources), BeforeDigest: layerDigest(before)}
}
// warrantOn is the router's warrant for a kept ask, choosing an option by id.
func warrantOn(a asked, option string, now time.Time) asks.Warrant {
o, _ := a.Ask.Option(option)
return asks.Warrant{Ask: a.ID, Asker: "mesh-controller", About: a.Ask.About, Outcome: asks.OutcomeChosen, Option: o.ID,
Label: o.Label, Level: o.Level, Channel: "telegram", Proofs: []string{"P1"}, At: now, AskDigest: a.Ask.Digest(),
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
}
type setCall struct {
p settingsProposal
ask string
setBy string
}
func withSetLayer(r *askerRig) *[]setCall {
var calls []setCall
r.a.setLayer = func(_ context.Context, p settingsProposal, askID, setBy string) (string, error) {
calls = append(calls, setCall{p, askID, setBy})
return "+ sources, ~ shares", nil
}
return &calls
}
// On Approve the layer is set once, with who approved it and through which channel kept beside it, and the warrant
// is recorded as the operator's decision; heard again, nothing more happens.
func TestTheLayerIsSetOnceOnTheOperatorsApproval(t *testing.T) {
r := newAskerRig(t)
calls := withSetLayer(r)
a := aProposalAsked(t, r, "s1", aProposal(r.now))
if err := r.a.reconcile(context.Background()); err != nil {
t.Fatal(err)
}
if got := r.store["s1"]; got.State != askOpen {
t.Fatalf("the reconciling of conditions ended the proposal: %+v", got)
}
w := warrantOn(a, "approve", r.now.Add(time.Hour))
answerWith(t, r, w)
answerWith(t, r, w) // heard again, or replayed
if len(*calls) != 1 {
t.Fatalf("set %d time(s): %+v", len(*calls), *calls)
}
c := (*calls)[0]
if c.ask != "s1" || c.p.Digest != layerDigest(mountsSources) ||
!strings.HasPrefix(c.setBy, "approved by the operator, as telegram identity 42 via telegram (telegram), user id verified at ") ||
!strings.Contains(c.setBy, "(ask s1, proposed by g14/claude-code)") {
t.Errorf("set by %q for %+v", c.setBy, c.p)
}
if len(r.called)+len(r.silenced) != 0 {
t.Errorf("a verb was called: %v %v", r.called, r.silenced)
}
if len(r.acts) != 1 {
t.Fatalf("hand-acts %+v", r.acts)
}
act := r.acts[0]
if act.Verb != handActWarrant || act.By != "the operator, as telegram identity 42" || act.Ask != "s1" ||
!slices.Contains(act.Args, "answer=approve") || !slices.Contains(act.Args, "values="+layerDigest(mountsSources)) ||
!strings.HasPrefix(act.Outcome, "done") || !personsDecision(act) {
t.Errorf("the record: %+v", act)
}
if got := r.store["s1"]; got.State != string(asks.OutcomeChosen) || !strings.HasPrefix(got.Acted, "done") {
t.Errorf("kept as %+v", got)
}
}
// Decline, expiry, the router's refusal, a cancel: nothing is set, and the proposal is recorded as ended.
func TestDeclineExpiryAndRefusalDiscardAProposal(t *testing.T) {
for name, outcome := range map[string]asks.Outcome{"declined": asks.OutcomeChosen, "expired": asks.OutcomeExpired,
"refused": asks.OutcomeRefused, "cancelled": asks.OutcomeCancelled, "replaced": asks.OutcomeReplaced} {
t.Run(name, func(t *testing.T) {
r := newAskerRig(t)
calls := withSetLayer(r)
a := aProposalAsked(t, r, "s2", aProposal(r.now))
w := warrantOn(a, "decline", r.now.Add(time.Hour))
if outcome != asks.OutcomeChosen {
w = asks.Warrant{Ask: a.ID, Asker: "mesh-controller", Outcome: outcome, Words: "its time passed", At: r.now.Add(time.Hour)}
}
answerWith(t, r, w)
if len(*calls) != 0 {
t.Fatalf("set: %+v", *calls)
}
got := r.store["s2"]
if got.State != string(outcome) || got.Acted == "" || !strings.HasPrefix(got.Acted, "nothing") {
t.Errorf("kept as %+v", got)
}
if outcome == asks.OutcomeChosen && (len(r.acts) != 1 || !strings.Contains(r.acts[0].Outcome, "declined")) {
t.Errorf("a decline is a decision too: %+v", r.acts)
}
// An approval after it ended is refused.
answerWith(t, r, warrantOn(a, "approve", r.now.Add(2*time.Hour)))
if len(*calls) != 0 {
t.Fatalf("set after the end: %+v", *calls)
}
})
}
}
// The warrant binds the exact values: a record whose values changed after the ask, an action changed, a warrant for
// another ask's digest, at another level, or after expiry each set nothing.
func TestAWarrantSetsOnlyTheExactValuesTheOperatorWasShown(t *testing.T) {
cases := map[string]func(r *askerRig, a asked) asks.Warrant{
"the values changed in the record": func(r *askerRig, a asked) asks.Warrant {
a.Proposal.Values = map[string]any{"sources": "recalbox=smb://evil/recalbox@/mnt/recalbox", "shares": "library=/mnt/library"}
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"the layer it was shown against changed in the record": func(r *askerRig, a asked) asks.Warrant {
a.Proposal.Before = map[string]any{"shares": "other"}
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"the module changed in the record": func(r *askerRig, a asked) asks.Warrant {
a.Proposal.Module = "sshd"
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"the machine changed in the record": func(r *askerRig, a asked) asks.Warrant {
a.Proposal.Node = "anchor"
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"the removal became meant in the record": func(r *askerRig, a asked) asks.Warrant {
a.Proposal.Replace = !a.Proposal.Replace
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"the set became a clear in the record": func(r *askerRig, a asked) asks.Warrant {
a.Proposal.Clear = true
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"the action's digest was changed": func(r *askerRig, a asked) asks.Warrant {
a.Actions[0].Arguments["values"] = layerDigest(map[string]any{"sources": "x"})
r.store[a.ID] = a
return warrantOn(a, "approve", r.now.Add(time.Hour))
},
"another ask's digest": func(r *askerRig, a asked) asks.Warrant {
w := warrantOn(a, "approve", r.now.Add(time.Hour))
w.AskDigest = "sha256:0000"
return w
},
"at the level acknowledge": func(r *askerRig, a asked) asks.Warrant {
w := warrantOn(a, "approve", r.now.Add(time.Hour))
w.Level = asks.Acknowledge
return w
},
"after expiry": func(r *askerRig, a asked) asks.Warrant {
return warrantOn(a, "approve", r.now.Add(askApproveFor+time.Minute))
},
"nobody chose": func(r *askerRig, a asked) asks.Warrant {
w := warrantOn(a, "approve", r.now.Add(time.Hour))
w.By = nil
return w
},
"another asker's": func(r *askerRig, a asked) asks.Warrant {
w := warrantOn(a, "approve", r.now.Add(time.Hour))
w.Asker = "claude-code"
return w
},
}
for name, tamper := range cases {
t.Run(name, func(t *testing.T) {
r := newAskerRig(t)
calls := withSetLayer(r)
a := aProposalAsked(t, r, "s3", aProposal(r.now))
answerWith(t, r, tamper(r, a))
if len(*calls) != 0 {
t.Fatalf("set: %+v", *calls)
}
if got := r.store["s3"]; strings.HasPrefix(got.Acted, "done") {
t.Errorf("kept as done: %+v", got)
}
})
}
}
// A proposal counts toward what the controller holds open, so a fourth ask is not attempted while three are.
func TestOpenProposalsCountTowardTheAsksHeldOpen(t *testing.T) {
r := newAskerRig(t)
for _, id := range []string{"s4", "s5", "s6"} {
aProposalAsked(t, r, id, aProposal(r.now))
}
r.open = []conditions.Condition{heldCondition()}
if err := r.a.reconcile(context.Background()); err != nil {
t.Fatal(err)
}
if len(r.sent) != 0 {
t.Errorf("asked beyond the bound: %d", len(r.sent))
}
for _, id := range []string{"s4", "s5", "s6"} {
if r.store[id].State != askOpen {
t.Errorf("%s was ended by the reconciling of conditions: %+v", id, r.store[id])
}
}
}
// ---- the verb ---------------------------------------------------------------------------------------
func TestTheSettingsVerbComposesProposeAndProposals(t *testing.T) {
for name, c := range map[string]struct {
args map[string]any
want string
}{
"propose on a machine": {map[string]any{"module": "mounts", "node": "shanks", "values": `{"sources":"x"}`, "propose": "true"},
"settings propose mounts {\"sources\":\"x\"} --node shanks"},
"propose with replace": {map[string]any{"module": "mounts", "values": `{"a":1}`, "propose": "true", "replace": "true"},
"settings propose mounts {\"a\":1} --replace"},
"propose a clear": {map[string]any{"module": "mounts", "node": "shanks", "propose": "true", "clear": "true"},
"settings propose mounts --clear --node shanks"},
"the proposals": {map[string]any{"proposals": "true"}, "settings proposals"},
"one proposal": {map[string]any{"proposal": "s1"}, "settings proposals s1"},
} {
argv, err := argvFor("settings", c.args)
if err != nil || strings.Join(argv, " ") != c.want {
t.Errorf("%s: %v %v", name, argv, err)
}
}
for name, args := range map[string]map[string]any{
"propose without a module": {"values": `{"a":1}`, "propose": "true"},
"propose without values": {"module": "mounts", "propose": "true"},
"propose values and clear": {"module": "mounts", "values": `{"a":1}`, "clear": "true", "propose": "true"},
"proposals with a module": {"proposals": "true", "module": "mounts"},
"proposals and a proposal": {"proposals": "true", "proposal": "s1"},
"a proposal with values": {"proposal": "s1", "values": `{"a":1}`},
"proposals with a listing": {"proposals": "true", "list": "preferences"},
} {
if _, err := argvFor("settings", args); err == nil {
t.Errorf("%s was composed", name)
}
}
// The generic command verb proposes nothing (it is the settings verb's), and lists proposals (a read).
if err := refusedAsTheGenericCommand([]string{"settings", "propose", "mounts", "{}", "--node", "shanks"}); err == nil {
t.Error("the generic command proposed")
}
if err := refusedAsTheGenericCommand([]string{"settings", "proposals"}); err != nil {
t.Errorf("the generic command may not list proposals: %v", err)
}
}
// ---- on the real stores -----------------------------------------------------------------------------
// setLayerIn sets the layer as the terminal does — judged, the removal meant, the history kept — with who approved it
// beside it; and refuses once the layer is no longer the one the operator was shown the change against.
func TestSetOnAWarrantJudgesTheLayerAndKeepsWhoApprovedIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
// y is a preference with a default, so a layer may leave it out; x is the operator's own (ADR 0262).
Settings: map[string]catalogue.SettingDeclaration{"y": {Kind: "preference", Default: "10", Why: "a size"}},
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"},
// A trusted file (unmarked), so its keys are the terminal's — and now the warrant's (novox/hq ADR 0277).
{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "content": "x = ${setting:x}\ny = ${setting:y}\n"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
set := setLayerIn(open)
now := time.Now()
first := map[string]any{"x": "1", "y": "2"}
p := settingsProposal{Module: "notes", Node: "laptop", Values: first, From: "g14/claude-code", At: now,
Digest: layerDigest(first), BeforeDigest: layerDigest(nil)}
changed, err := set(ctx, p, "s9", "approved by the operator, as telegram identity 42 via telegram at 14:05 (ask s9)")
if err != nil || !strings.Contains(changed, "+ x") {
t.Fatalf("%q %v", changed, err)
}
layer, has, err := open.inventory.Layer(ctx, "laptop", "notes")
if err != nil || !has || layer["x"] != "1" {
t.Fatalf("the layer: %v %v %v", layer, has, err)
}
setBy, _, has, err := open.inventory.LayerOrigin(ctx, "laptop", "notes")
if err != nil || !has || !strings.HasPrefix(setBy, "approved by the operator, as telegram identity 42 via telegram") {
t.Fatalf("who set it: %q %v", setBy, err)
}
// Shown by `settings show`, at the terminal and through the verb.
out := captureStdout(t, func() {
if err := atTheTerminal(t, "settings", "show", "notes", "--node", "laptop"); err != nil {
t.Fatal(err)
}
})
if !strings.Contains(out, "approved by the operator, as telegram identity 42 via telegram") {
t.Errorf("settings show says:\n%s", out)
}
// The same proposal again: the layer is no longer the one the operator was shown it against.
if _, err := set(ctx, p, "s9", "approved …"); err == nil || !strings.Contains(err.Error(), "changed since the operator was shown") {
t.Errorf("a stale proposal: %v", err)
}
// A removal not meant is refused; one meant is taken, and the history says who had set the layer.
second := map[string]any{"x": "1"}
q := settingsProposal{Module: "notes", Node: "laptop", Values: second, Before: first, HadLayer: true,
From: "g14/claude-code", At: now, Digest: layerDigest(second), BeforeDigest: layerDigest(first)}
if _, err := set(ctx, q, "s10", "approved …"); err == nil || !strings.Contains(err.Error(), "removal was not meant") {
t.Errorf("a silent removal: %v", err)
}
// A layer the mesh refuses is refused here too, with the same words as at the terminal.
bad := q
bad.Values, bad.Digest = map[string]any{"x": "a\nb", "y": "2"}, layerDigest(map[string]any{"x": "a\nb", "y": "2"})
if _, err := set(ctx, bad, "s11", "approved …"); err == nil || !strings.Contains(err.Error(), "line break") {
t.Errorf("a line break on a warrant: %v", err)
}
if layer, _, _ := open.inventory.Layer(ctx, "laptop", "notes"); layer["y"] != "2" {
t.Fatalf("a refused act changed the layer: %v", layer)
}
q.Replace = true
if _, err := set(ctx, q, "s10", "approved later"); err != nil {
t.Fatal(err)
}
past, err := open.inventory.SettingsHistory(ctx, "laptop", "notes")
if err != nil || len(past) != 1 || !strings.HasPrefix(past[0].SetBy, "approved by the operator") {
t.Errorf("the history: %+v %v", past, err)
}
// And a clear, keeping who cleared it with the copy.
c := settingsProposal{Module: "notes", Node: "laptop", Clear: true, Before: second, HadLayer: true, From: "x", At: now,
Digest: layerDigest(second), BeforeDigest: layerDigest(second)}
if _, err := set(ctx, c, "s12", "approved by the operator at 15:00"); err != nil {
t.Fatal(err)
}
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
t.Error("the layer was not cleared")
}
past, _ = open.inventory.SettingsHistory(ctx, "laptop", "notes")
if len(past) != 2 || !strings.Contains(past[0].SetBy, "cleared approved by the operator at 15:00") {
t.Errorf("the history after a clear: %+v", past)
}
}
// A layer set at the terminal says so, and one set through a verb names the verb (novox/hq ADR 0277).
func TestALayerSaysWhoSetIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644", "trusted": false,
"content": "x = ${setting:x}\n"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"1"}`, "--node", "laptop"); err != nil {
t.Fatal(err)
}
setBy, _, _, _ := open.inventory.LayerOrigin(ctx, "laptop", "notes")
if !strings.HasPrefix(setBy, "set at the controller's terminal by ") {
t.Errorf("at the terminal: %q", setBy)
}
if err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop", "values": `{"x":"2"}`}); err != nil {
t.Fatal(err)
}
setBy, _, _, _ = open.inventory.LayerOrigin(ctx, "laptop", "notes")
if !strings.HasPrefix(setBy, "set by ") || !strings.Contains(setBy, "through settings") {
t.Errorf("through the verb: %q", setBy)
}
}
// A trusted setting is still refused through the settings verb (novox/hq issue 339), and the refusal now names the
// proposal as the way.
func TestATrustedSettingThroughAVerbNamesTheProposal(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Resources: []map[string]any{{"id": "data", "type": "directory", "mode": "0755"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
err := throughVerb(t, "settings", map[string]any{"module": "notes", "node": "laptop",
"values": `{"places":{"data":{"path":"/srv/notes","owner":"1001:1001"}}}`})
if err == nil || !strings.Contains(err.Error(), "issue 339") || !strings.Contains(err.Error(), "propose") {
t.Errorf("%v", err)
}
if _, has, _ := open.inventory.Layer(ctx, "laptop", "notes"); has {
t.Error("a layer was kept")
}
}
// captureStdout runs f and answers what it printed to standard output.
func captureStdout(t *testing.T, f func()) string {
t.Helper()
before := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
done := make(chan string)
go func() {
var b strings.Builder
_, _ = io.Copy(&b, r)
done <- b.String()
}()
f()
os.Stdout = before
_ = w.Close()
return <-done
}
+36 -5
View File
@@ -260,10 +260,10 @@ func refusedAsTheGenericCommand(argv []string) error {
// A layer is written through the settings verb, never the generic one (novox/hq issue 339): the
// settings verb is where what a verb may not set is refused, and one route is one set of words.
// The command refuses places and accesses through any verb as well; this says so before it runs.
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" }) {
return &heldAtTheTerminal{msg: "settings are set and cleared through the settings verb, not the " +
"generic command; and places and accesses only at the controller's terminal (novox/hq issue 339). " +
"Nothing was done"}
if argv[0] == "settings" && slices.ContainsFunc(argv[1:], func(w string) bool { return w == "set" || w == "clear" || w == "propose" }) {
return &heldAtTheTerminal{msg: "settings are set, cleared and proposed through the settings verb, not the " +
"generic command; and places and accesses are set at the controller's terminal or on the operator's " +
"warrant (novox/hq issue 339, ADR 0277). Nothing was done"}
}
// The generic verb only reads (novox/hq ADR 0266): what writes has a named verb that composes its own
// line, or is the operator's at the controller's terminal.
@@ -835,6 +835,21 @@ func (a *verbArguments) commandLine() ([]string, error) {
if str("module") == "" && on("clear") {
return nil, errors.New("settings: a module is needed to clear a layer; name it with module")
}
// The proposals, listed or one whole (novox/hq ADR 0277): a read, needing no module.
if on("proposals") || str("proposal") != "" {
if str("module") != "" || str("values") != "" || str("node") != "" || on("clear") || on("replace") ||
on("history") || str("list") != "" || on("propose") || (on("proposals") && str("proposal") != "") {
return nil, errors.New("settings: proposals lists what was proposed and proposal shows one; either takes nothing else")
}
argv := []string{"settings", "proposals"}
if id := str("proposal"); id != "" {
argv = append(argv, id)
}
return argv, nil
}
if str("module") == "" && on("propose") {
return nil, errors.New("settings: a module is needed to propose a layer; name it with module")
}
if list := str("list"); list != "" || str("module") == "" {
if list != "" && list != "preferences" {
return nil, fmt.Errorf("settings lists %q only; %q is not a listing", "preferences", list)
@@ -855,6 +870,22 @@ func (a *verbArguments) commandLine() ([]string, error) {
}
var argv []string
switch {
case on("propose"):
// A proposal: the values, or clear, put to the operator (novox/hq ADR 0277). Nothing is set here.
argv = []string{"settings", "propose", str("module")}
switch {
case on("clear") && str("values") != "":
return nil, errors.New("settings: a proposal gives values or says clear, not both")
case on("clear"):
argv = append(argv, "--clear")
case str("values") != "":
argv = append(argv, str("values"))
if on("replace") {
argv = append(argv, "--replace")
}
default:
return nil, errors.New("settings: a proposal gives the values, or says clear")
}
case on("clear"):
argv = []string{"settings", "clear", str("module")}
case str("values") != "":
@@ -1447,7 +1478,7 @@ var commandReadForms = map[string]func(rest []string) bool{
"conditions": func(r []string) bool { return flagsOnly(r) || subIn(r, "list", "show", "history") },
"node": func(r []string) bool { return subIn(r, "list", "show") },
"module": func(r []string) bool { return subIn(r, "list") },
"settings": func(r []string) bool { return subIn(r, "show", "preferences") },
"settings": func(r []string) bool { return subIn(r, "show", "preferences", "proposals") },
"retire": func(r []string) bool { return subIn(r, "list") },
"cleanup": func(r []string) bool { return subIn(r, "list") },
"delivery": func(r []string) bool { return subIn(r, "plan", "walks") },
+14 -5
View File
@@ -268,17 +268,26 @@ var ControllerVerbs = []Verb{
"with history, the layers it replaced. Setting replaces that layer whole and answers each key it adds (+), " +
"changes (~) and removes (-); a set that would remove a key is refused unless replace says it is meant " +
"(novox/hq ADR 0217). Takes effect at the next push. With clear, removes the layer and the module is back to " +
"what its definition says; a cleared or replaced layer is kept in the history.",
"what its definition says; a cleared or replaced layer is kept in the history. A trusted setting — " +
"places, accesses, what a provider serves, what a trusted file asks for, the whole layer of a module " +
"with a trusted mergeable file — is refused through this verb (novox/hq issue 339, 340) and PROPOSED " +
"instead (novox/hq ADR 0277): with propose, the values (or clear) are put to the operator on a channel " +
"that proves who answers, with every key and its exact new value, and the layer is set only on their " +
"Approve; the answer names the proposal, its fingerprint and when it expires, and nothing changes until " +
"then. With proposals, every proposal and where each stands; with proposal, one whole.",
Input: schema(map[string]string{
"module": "the module's name; with list, only that module's",
"values": "the settings as a JSON object, for set",
"values": "the settings as a JSON object, for set or propose",
"node": "one machine; the whole mesh when absent",
"clear": "\"true\" to remove the layer instead of setting it; not with values",
"replace": "\"true\": with values, the set is meant to remove the keys the layer had and it does not name",
"clear": "\"true\" to remove the layer instead of setting it, or to propose its removal; not with values",
"replace": "\"true\": with values, the set (or the proposal) is meant to remove the keys the layer had and it does not name",
"history": "\"true\": without values or clear, the layers this one replaced, the latest first",
"list": "\"preferences\": every module's preferences — key, default and why — and the value on each " +
"machine it is assigned to with where it comes from; module and node narrow it (novox/hq ADR 0262)",
}, nil, "clear", "replace", "history")},
"propose": "\"true\": propose the values (or clear) to the operator instead of setting them (novox/hq ADR 0277)",
"proposals": "\"true\": every settings proposal, newest first, and where each stands",
"proposal": "a proposal's id: that proposal whole, its values and the layer it was shown against",
}, nil, "clear", "replace", "history", "propose", "proposals")},
{Name: "command", Description: "Run one reading command line of the controller's own, as you would type it at " +
"its shell — `node show ace`, `module list`, `plans`, `conditions show <key>` — and answer what it " +
"printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " +
+51 -7
View File
@@ -799,7 +799,35 @@ func profileFrom(raw []byte) ([]Capability, error) {
// this is a statement of the whole layer, so removing a key is done by leaving it out, which is
// the only way removing one could work at all.
func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
return i.setSettings(ctx, nodeName, module, values, true)
return i.setSettings(ctx, nodeName, module, values, true, "")
}
// SetSettingsBy is SetSettings with who set the layer kept beside it (novox/hq ADR 0277): "approved by the
// operator via telegram …" for a layer set on a warrant, the caller at the controller's terminal otherwise.
// `settings` says it back, so a layer the operator approved on their phone is told from one typed.
func (i *Inventory) SetSettingsBy(ctx context.Context, nodeName, module string, values map[string]any, setBy string) error {
return i.setSettings(ctx, nodeName, module, values, true, setBy)
}
// JudgeSettings judges a layer as SetSettings would, and keeps nothing: what a proposal is held to before the
// operator is asked (novox/hq ADR 0277), so an ask is never raised for a layer the mesh would refuse.
func (i *Inventory) JudgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
if err := i.judgeSettings(ctx, nodeName, module, values); err != nil {
return err
}
raw, err := json.Marshal(values)
if err != nil {
return err
}
given, err := givenIn(module, raw)
if err != nil {
return err
}
if nodeName == "" && len(given) > 0 {
return fmt.Errorf("%s: %s is given per node — a port is a fact about one machine; "+
"set it with --node", module, catalogue.PortsSetting)
}
return nil
}
// KeepSettings records a layer the mesh already holds, as it holds it, without judging it alone: for a
@@ -807,10 +835,10 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va
// mesh-wide layer that needs a machine's own value to compose would be refused before that machine's
// layer is there — though the mesh keeps both and composes. Composition still judges every layer.
func (i *Inventory) KeepSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
return i.setSettings(ctx, nodeName, module, values, false)
return i.setSettings(ctx, nodeName, module, values, false, "")
}
func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, values map[string]any, judge bool) error {
func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, values map[string]any, judge bool, setBy string) error {
raw, err := json.Marshal(values)
if err != nil {
return err
@@ -848,9 +876,9 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
return err
}
if _, err := tx.Exec(ctx,
`insert into settings (node, module, values) values (null, $1, $2)
`insert into settings (node, module, values, set_by) values (null, $1, $2, $3)
on conflict (module) where node is null
do update set values = excluded.values, set_at = now()`, module, raw); err != nil {
do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, module, raw, nullable(setBy)); err != nil {
return wrapModule(err, module)
}
return tx.Commit(ctx)
@@ -878,9 +906,9 @@ func (i *Inventory) setSettings(ctx context.Context, nodeName, module string, va
return err
}
_, err = tx.Exec(ctx,
`insert into settings (node, module, values) values ($1, $2, $3)
`insert into settings (node, module, values, set_by) values ($1, $2, $3, $4)
on conflict (node, module) where node is not null
do update set values = excluded.values, set_at = now()`, node.ID, module, raw)
do update set values = excluded.values, set_at = now(), set_by = excluded.set_by`, node.ID, module, raw, nullable(setBy))
if err != nil {
return wrapModule(err, module)
}
@@ -1069,6 +1097,11 @@ func wrapModule(err error, module string) error {
// ClearSettings removes a layer.
func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string) error {
return i.ClearSettingsBy(ctx, nodeName, module, "")
}
// ClearSettingsBy removes a layer, and keeps who cleared it with the history copy (novox/hq ADR 0277).
func (i *Inventory) ClearSettingsBy(ctx context.Context, nodeName, module, clearedBy string) error {
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return err
@@ -1082,6 +1115,17 @@ func (i *Inventory) ClearSettings(ctx context.Context, nodeName, module string)
if _, err := tx.Exec(ctx, keepReplacedSQL, module, nodeID, "clear"); err != nil {
return err
}
if clearedBy != "" {
// The history copy says who cleared it, beside who had set it.
if _, err := tx.Exec(ctx,
`update settings_history set set_by = coalesce(set_by, '') || ' — cleared ' || $3
where module = $1 and node is not distinct from $2::uuid
and replaced_at = (select max(replaced_at) from settings_history
where module = $1 and node is not distinct from $2::uuid)`,
module, nodeID, clearedBy); err != nil {
return err
}
}
if _, err := tx.Exec(ctx,
`delete from settings where module = $1 and node is not distinct from $2::uuid`, module, nodeID); err != nil {
return err
@@ -0,0 +1,8 @@
-- A trusted setting set on the operator's warrant (novox/hq ADR 0277): a layer says who set it.
--
-- Until now a layer carried only when it was set: a layer the operator approved on their phone and one typed
-- at the controller's terminal looked the same, and `settings` could not say "approved by the operator via
-- telegram". Kept with the layer, and with the history copy of it, so the provenance of a replaced layer is
-- read back beside its values.
alter table settings add column set_by text;
alter table settings_history add column set_by text;
+33 -4
View File
@@ -44,6 +44,31 @@ type PastLayer struct {
ReplacedAt time.Time
// ReplacedBy is "set" or "clear".
ReplacedBy string
// SetBy is who had set the layer, when it was kept (novox/hq ADR 0277); empty for one set before that was kept.
SetBy string
}
// LayerOrigin is who set a layer and when (novox/hq ADR 0277): empty words for a layer set before who set it was
// kept, and has false where there is no layer.
func (i *Inventory) LayerOrigin(ctx context.Context, nodeName, module string) (setBy string, setAt time.Time, has bool, err error) {
nodeID, err := i.layerNode(ctx, nodeName)
if err != nil {
return "", time.Time{}, false, err
}
var by *string
err = i.store.Pool().QueryRow(ctx,
`select set_by, set_at from settings where module = $1 and node is not distinct from $2::uuid`,
module, nodeID).Scan(&by, &setAt)
if errors.Is(err, pgx.ErrNoRows) {
return "", time.Time{}, false, nil
}
if err != nil {
return "", time.Time{}, false, err
}
if by != nil {
setBy = *by
}
return setBy, setAt, true, nil
}
// SettingsHistory is every layer of one module on one machine — the whole mesh's when nodeName is
@@ -54,7 +79,7 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select values, set_at, replaced_at, replaced_by from settings_history
`select values, set_at, replaced_at, replaced_by, set_by from settings_history
where module = $1 and node is not distinct from $2::uuid order by replaced_at desc`,
module, nodeID)
if err != nil {
@@ -65,9 +90,13 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string
for rows.Next() {
var raw []byte
var p PastLayer
if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy); err != nil {
var by *string
if err := rows.Scan(&raw, &p.SetAt, &p.ReplacedAt, &p.ReplacedBy, &by); err != nil {
return nil, err
}
if by != nil {
p.SetBy = *by
}
if err := json.Unmarshal(raw, &p.Values); err != nil {
return nil, err
}
@@ -78,8 +107,8 @@ func (i *Inventory) SettingsHistory(ctx context.Context, nodeName, module string
// keepReplacedSQL copies a layer into the history before it is replaced or cleared; run in the same
// transaction as the write where there is one, so a write that fails leaves no history of it.
const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by)
select node, module, values, set_at, $3 from settings
const keepReplacedSQL = `insert into settings_history (node, module, values, set_at, replaced_by, set_by)
select node, module, values, set_at, $3, set_by from settings
where module = $1 and node is not distinct from $2::uuid`
// layerNode is the node id of a layer, nil for the whole mesh's.