diff --git a/cmd/mesh-controller/asker.go b/cmd/mesh-controller/asker.go index bec3562e..8a7233dd 100644 --- a/cmd/mesh-controller/asker.go +++ b/cmd/mesh-controller/asker.go @@ -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() } diff --git a/cmd/mesh-controller/asker_wire.go b/cmd/mesh-controller/asker_wire.go index 72b0281a..345cf5e5 100644 --- a/cmd/mesh-controller/asker_wire.go +++ b/cmd/mesh-controller/asker_wire.go @@ -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 diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 51128962..9929b2e0 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -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 [--node ] [--history], settings set " + - "[--node ] [--replace], settings clear [--node ], or settings preferences " + - "[] [--node ]") + "[--node ] [--replace], settings clear [--node ], settings preferences " + + "[] [--node ], settings propose [--node ] [--replace], " + + "or settings proposals []") } 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 [--node ] [--replace], or settings propose --clear [--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 := "" + 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 } diff --git a/cmd/mesh-controller/proposals.go b/cmd/mesh-controller/proposals.go new file mode 100644 index 00000000..124aee3d --- /dev/null +++ b/cmd/mesh-controller/proposals.go @@ -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 [--node ] [--replace] +// mesh-controller settings propose --clear [--node ] +// mesh-controller settings proposals [] +// +// 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 ` — 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 [--node ] [--replace], or settings propose --clear [--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 []`: 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 + } +} diff --git a/cmd/mesh-controller/proposals_test.go b/cmd/mesh-controller/proposals_test.go new file mode 100644 index 00000000..2a405d2c --- /dev/null +++ b/cmd/mesh-controller/proposals_test.go @@ -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 +} diff --git a/cmd/mesh-controller/seatverbs.go b/cmd/mesh-controller/seatverbs.go index 16241347..ddbdb56d 100644 --- a/cmd/mesh-controller/seatverbs.go +++ b/cmd/mesh-controller/seatverbs.go @@ -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") }, diff --git a/internal/catalogue/verbs.go b/internal/catalogue/verbs.go index c85c952a..f7757c65 100644 --- a/internal/catalogue/verbs.go +++ b/internal/catalogue/verbs.go @@ -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 ` — and answer what it " + "printed. The generic verb beside the named ones (novox/hq ADR 0154), and since ADR 0266 it only reads: " + diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index 0c3b1332..6ce3740c 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -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 diff --git a/internal/inventory/migrations/0090-a-layer-says-who-set-it.sql b/internal/inventory/migrations/0090-a-layer-says-who-set-it.sql new file mode 100644 index 00000000..63293977 --- /dev/null +++ b/internal/inventory/migrations/0090-a-layer-says-who-set-it.sql @@ -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; diff --git a/internal/inventory/unseen.go b/internal/inventory/unseen.go index be7e482b..8acbf13b 100644 --- a/internal/inventory/unseen.go +++ b/internal/inventory/unseen.go @@ -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.