929 lines
34 KiB
Go
929 lines
34 KiB
Go
package main
|
||
|
||
// A config value proposed for the operator's Approve (novox/hq ADR 0348 §8, ADR 0277's rules with mesh-config as
|
||
// the asker; design 57 K4).
|
||
//
|
||
// Whoever the bus admits may PROPOSE a value at one level — mesh config, node config or a module's own value on a
|
||
// node — a trusted one among them, which the verb sets only from the controller's terminal. A proposal changes
|
||
// nothing. It is judged as a set judges it, then kept in mesh-config_proposals (which this module's account alone
|
||
// writes) with the value whole, the value stored at that level when it was proposed (what the operator is shown
|
||
// the change against), who proposed it, why and when. It is asked of the operator **under mesh-config's own name**
|
||
// on the operator-channel seat — its own grant, from its manifest's `uses`, and its own cap of open asks — as an
|
||
// ask whose two answers, Approve and Decline, are both at the level approve, so only a channel that proves who
|
||
// answered carries either. The ask says the level, the key and the value, and that, approved, it takes effect at
|
||
// once on every node it reaches; each option binds the proposal's level, key, the digest of the exact value and of
|
||
// the value it was shown against.
|
||
//
|
||
// **On the answer** mesh-config reads the router's own record of its ask (which only the router writes, and this
|
||
// module reads under its own name alone), composes the ask again from the proposal and holds the warrant to it:
|
||
// the same ask digest, an option it offered, at its level, in time (asks.Warrant.For), and the act the option
|
||
// bound is this proposal's (asks.Option.Performs). A record whose value or shown value changed after it was asked
|
||
// is refused, and so is an Approve when the value stored at the level is no longer the one the operator was shown
|
||
// the change against. Then the value is set exactly as a set at the terminal is, with the same judgement and
|
||
// history, and who approved it is kept beside the value; the warrant is recorded in the controller's hand-act log.
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||
"git.novox.be/novox/mesh-sdk/go/outward"
|
||
)
|
||
|
||
// Module is this module's name: the one the bus grants its asks under.
|
||
const Module = "mesh-config"
|
||
|
||
// ProposalVerb is the act a proposal's answers bind.
|
||
const ProposalVerb = Seat + "." + Verb
|
||
|
||
// ProposalLasts is how long the operator has to answer: a day less ten minutes (ADR 0277 §9), under the router's
|
||
// bound for an approval.
|
||
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
|
||
|
||
// How long a proposal waits for the router's word on its new ask, and how often it looks (ADR 0277 §20).
|
||
const (
|
||
RouterAnswersWithin = 8 * time.Second
|
||
RouterAnswersEvery = 250 * time.Millisecond
|
||
)
|
||
|
||
// The two answers, both at the level approve.
|
||
const (
|
||
OptionApprove = "approve"
|
||
OptionDecline = "decline"
|
||
)
|
||
|
||
// Where a proposal stands.
|
||
const (
|
||
ProposalWaiting = "waiting"
|
||
ProposalApproved = "approved"
|
||
ProposalDeclined = "declined"
|
||
ProposalEnded = "ended"
|
||
ProposalRefused = "refused"
|
||
ProposalUnasked = "not asked"
|
||
)
|
||
|
||
// AskBus is the operator channel as this module reaches it: publish an ask under its own name, read the router's
|
||
// record of one of its asks, the kinds of the channel bench with what each promises, and the controller's
|
||
// hand-act log for a warrant acted on.
|
||
type AskBus interface {
|
||
Publish(subject string, body any, id string) error
|
||
Record(id string) (*stdio.StateEntry, error)
|
||
Kinds() ([]stdio.KindHeld, error)
|
||
Warranted(ask string) error
|
||
}
|
||
|
||
// Proposal is one value proposed, as mesh-config_proposals keeps it. Every field the ask is composed from is here,
|
||
// so the ask is composed again from it alone to hold the warrant to.
|
||
type Proposal struct {
|
||
ID string `json:"id"`
|
||
Action string `json:"action"`
|
||
Level Level `json:"level"`
|
||
Key string `json:"key"`
|
||
// Value is the value proposed, whole, and Digest its digest (valueDigest); a clear carries none.
|
||
Value json.RawMessage `json:"value,omitempty"`
|
||
Digest string `json:"digest"`
|
||
// Was is the value stored at the level when it was proposed, HadValue whether there was one, and WasDigest its
|
||
// digest: what the operator is shown the change against, and what must still stand on Approve.
|
||
Was json.RawMessage `json:"was,omitempty"`
|
||
HadValue bool `json:"had-value"`
|
||
WasDigest string `json:"was-digest"`
|
||
Trusted bool `json:"trusted,omitempty"`
|
||
// Reaches is each module on each node whose value it moves, as it was judged when proposed.
|
||
Reaches []string `json:"reaches"`
|
||
// From is who proposed it, as the bus proved the caller; Why is theirs; At is when.
|
||
From string `json:"from"`
|
||
Why string `json:"why"`
|
||
At time.Time `json:"at"`
|
||
|
||
// State is where it stands, Outcome what came of it in words, Decided when; Answered is the warrant a person's
|
||
// choice held on (Approve or Decline), and Approved who approved it.
|
||
State string `json:"state"`
|
||
Outcome string `json:"outcome,omitempty"`
|
||
Decided time.Time `json:"decided,omitempty"`
|
||
Answered string `json:"answered,omitempty"`
|
||
Approved string `json:"approved,omitempty"`
|
||
// Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not.
|
||
Recorded bool `json:"recorded,omitempty"`
|
||
Record string `json:"record,omitempty"`
|
||
}
|
||
|
||
// approval is what a decided proposal gives the set it makes: the warrant in words, and what the operator was
|
||
// shown the change against.
|
||
type approval struct {
|
||
Proposal string
|
||
By string
|
||
Says string
|
||
Was json.RawMessage
|
||
HadValue bool
|
||
WasDigest string
|
||
}
|
||
|
||
// valueDigest is the digest a proposal binds of a value: SHA-256 over its one encoding (compact), "none" when
|
||
// there is no value.
|
||
func valueDigest(raw json.RawMessage, had bool) string {
|
||
text := "none"
|
||
if had {
|
||
text = "value:" + string(compact(raw))
|
||
}
|
||
sum := sha256.Sum256([]byte(text))
|
||
return "sha256:" + hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func shownOrNone(raw json.RawMessage, had bool) string {
|
||
if !had {
|
||
return "no value set"
|
||
}
|
||
return string(compact(raw))
|
||
}
|
||
|
||
// fingerprint is a digest as the operator is shown it: its first 24 hexadecimal digits in groups of four.
|
||
func fingerprint(digest string) string {
|
||
h := strings.TrimPrefix(digest, "sha256:")
|
||
if len(h) < 24 {
|
||
return h
|
||
}
|
||
var g []string
|
||
for i := 0; i < 24; i += 4 {
|
||
g = append(g, h[i:i+4])
|
||
}
|
||
return strings.Join(g, " ")
|
||
}
|
||
|
||
// Fingerprint is the proposal as the operator is shown it: the value proposed, or for a clear the value it clears.
|
||
func (p Proposal) Fingerprint() string {
|
||
if p.Action == "clear" {
|
||
return fingerprint(p.WasDigest)
|
||
}
|
||
return fingerprint(p.Digest)
|
||
}
|
||
|
||
// about is what its ask is about: the value's place, so a newer proposal for it replaces an older one still open
|
||
// (ADR 0277 §10, at a level, a key).
|
||
func (p Proposal) about() string { return "config." + p.Level.ValueKey(p.Key) }
|
||
|
||
// Act is what an answer binds: this proposal's level, key, the digest of its exact value and of the value it was
|
||
// shown against, who proposed it and when, and the answer.
|
||
func (p Proposal) Act(option string) asks.Act {
|
||
return asks.Act{"verb": ProposalVerb, "option": option, "proposal": p.ID, "action": p.Action,
|
||
"level": p.Level.Kind, "node": p.Level.Node, "module": p.Level.Module, "key": p.Key, "value": p.Digest,
|
||
"was": p.WasDigest, "had-value": strconv.FormatBool(p.HadValue), "from": p.From,
|
||
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
||
}
|
||
|
||
// ---- what the operator reads --------------------------------------------------------------------------
|
||
|
||
// Markers for what a phone is not shown.
|
||
const (
|
||
markAddress = "‹address›"
|
||
markPath = "‹path›"
|
||
markWithheld = "‹withheld›"
|
||
)
|
||
|
||
// sayable is a text as a channel that proves nothing is shown it, under the content rule (outward.Check): as it
|
||
// is, or the marker of what it carries.
|
||
func sayable(text string) (string, bool) {
|
||
refusal, ok := outward.Check(text)
|
||
if ok {
|
||
return text, true
|
||
}
|
||
switch refusal.Class {
|
||
case "address":
|
||
return markAddress, false
|
||
case "path":
|
||
return markPath, false
|
||
}
|
||
return markWithheld, false
|
||
}
|
||
|
||
// change is the change in one line, "+ key: value", "~ key: value (was: old)" or "- key (was: old)": each value
|
||
// exact (for a channel that proves who answers, ADR 0277 §6 and issue 383) or under the content rule.
|
||
func (p Proposal) change(exact bool) string {
|
||
say := func(raw json.RawMessage) string {
|
||
text := string(compact(raw))
|
||
if exact {
|
||
return text
|
||
}
|
||
s, _ := sayable(text)
|
||
return s
|
||
}
|
||
switch {
|
||
case p.Action == "clear":
|
||
return fmt.Sprintf("- %s (was: %s)", p.Key, say(p.Was))
|
||
case p.HadValue:
|
||
return fmt.Sprintf("~ %s: %s (was: %s)", p.Key, say(p.Value), say(p.Was))
|
||
}
|
||
return fmt.Sprintf("+ %s: %s", p.Key, say(p.Value))
|
||
}
|
||
|
||
// whole is whether every value is shown exactly under the content rule.
|
||
func (p Proposal) whole() bool { return p.change(false) == p.change(true) }
|
||
|
||
// reaches is every node it reaches, in words.
|
||
func (p Proposal) reaches() string {
|
||
nodes := map[string]bool{}
|
||
for _, r := range p.Reaches {
|
||
if i := strings.LastIndex(r, " on "); i > 0 {
|
||
nodes[r[i+4:]] = true
|
||
}
|
||
}
|
||
switch len(nodes) {
|
||
case 0:
|
||
return "none today, as it moves no module's value"
|
||
case 1:
|
||
return sortedKeys(nodes)[0] + " (" + strings.Join(p.Reaches, ", ") + ")"
|
||
}
|
||
list := sortedKeys(nodes)
|
||
return fmt.Sprintf("%d nodes, %s", len(list), strings.Join(list, ", "))
|
||
}
|
||
|
||
// summary is the ask's first line: the level, the key and the value, and that it takes effect at once.
|
||
func (p Proposal) summary() string {
|
||
verb := "Sets " + p.Key
|
||
if p.Action == "clear" {
|
||
verb = "Clears " + p.Key
|
||
}
|
||
trusted := ""
|
||
if p.Trusted {
|
||
trusted = ", a trusted value,"
|
||
}
|
||
reach := p.reaches()
|
||
s := fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches: %s.", verb, trusted,
|
||
p.Level, reach)
|
||
if len([]rune(s)) > asks.SummaryLength {
|
||
s = fmt.Sprintf("%s%s at %s; approved, it takes effect at once on every node it reaches (%d modules).", verb,
|
||
trusted, p.Level, len(p.Reaches))
|
||
}
|
||
return s
|
||
}
|
||
|
||
// point is a line cut to what a phone shows at a glance, saying so.
|
||
func point(line string) string {
|
||
const cut = "… (cut: read it in the note)"
|
||
if r := []rune(line); len(r) > asks.PointLength {
|
||
return string(r[:asks.PointLength-len([]rune(cut))]) + cut
|
||
}
|
||
return line
|
||
}
|
||
|
||
// headline is the ask's headline, shortened when the names make it longer than the router allows (ADR 0277 §5).
|
||
func (p Proposal) headline() string {
|
||
verb := "Set"
|
||
if p.Action == "clear" {
|
||
verb = "Clear"
|
||
}
|
||
where := "for the whole mesh"
|
||
switch p.Level.Kind {
|
||
case LevelNode:
|
||
where = "on " + p.Level.Node
|
||
case LevelModule:
|
||
where = "for " + p.Level.Module + " on " + p.Level.Node
|
||
}
|
||
for _, h := range []string{fmt.Sprintf("%s %s %s?", verb, p.Key, where), fmt.Sprintf("%s a config value %s?", verb,
|
||
where), verb + " a config value?"} {
|
||
if len([]rune(h)) <= asks.HeadlineLength {
|
||
if s, ok := sayable(h); ok {
|
||
return s
|
||
}
|
||
}
|
||
}
|
||
return verb + " a config value?"
|
||
}
|
||
|
||
// Ask is the proposal's ask, composed from it alone: its words, its two options each binding its act, who
|
||
// answers, its expiry and what it is about.
|
||
func (p Proposal) Ask() asks.Ask {
|
||
why, _ := sayable("Why: " + p.Why)
|
||
if why != "Why: "+p.Why {
|
||
why = "Why: given, and read in full with the proposal"
|
||
}
|
||
compose := func(exact bool) string {
|
||
return strings.Join([]string{p.summary(), point(p.change(exact)), point(why)}, "\n")
|
||
}
|
||
details := func(exact bool) string {
|
||
var d strings.Builder
|
||
d.WriteString(p.change(exact) + "\n")
|
||
from, _ := sayable(p.From)
|
||
fmt.Fprintf(&d, "Proposed by %s at %s UTC. Fingerprint %s.\n", from, p.At.UTC().Format("15:04 on 2 Jan"),
|
||
p.Fingerprint())
|
||
if !exact && !p.whole() {
|
||
d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
|
||
}
|
||
fmt.Fprintf(&d, "Read it whole, with this fingerprint: mesh-config's config verb with action proposals and "+
|
||
"proposal %s, through the mesh MCP server.", p.ID)
|
||
return d.String()
|
||
}
|
||
a := asks.Ask{ID: p.ID, Headline: p.headline(), Explanation: compose(false), Who: asks.Operator,
|
||
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes", About: p.about(),
|
||
Details: details(false)}
|
||
if w := compose(true); w != a.Explanation {
|
||
a.Whole = w
|
||
}
|
||
if w := details(true); w != a.Details {
|
||
a.WholeDetails = w
|
||
}
|
||
does := "the value is set at once, and every node it reaches takes it"
|
||
if p.Action == "clear" {
|
||
does = "the value is cleared at once, and every node it reaches takes what is below it"
|
||
}
|
||
for _, o := range []struct{ id, label, does string }{
|
||
{OptionApprove, "Approve", does},
|
||
{OptionDecline, "Decline", "nothing changes; the proposal is discarded"},
|
||
} {
|
||
binds, _ := asks.ActDigest(p.Act(o.id))
|
||
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve,
|
||
Binds: binds})
|
||
}
|
||
return a
|
||
}
|
||
|
||
// problem says why a proposal as kept is not one this module made, or "": judged again before an Approve is
|
||
// acted on, so a record whose value or shown value changed after it was asked is refused (ADR 0277 §12).
|
||
func (p Proposal) problem() string {
|
||
switch {
|
||
case !asks.UsableID(p.ID):
|
||
return "its id is not one"
|
||
case p.Action != "set" && p.Action != "clear":
|
||
return "its action is neither set nor clear"
|
||
case !keyName.MatchString(p.Key):
|
||
return "its key is not one"
|
||
case p.At.IsZero():
|
||
return "it says no time"
|
||
case p.Action == "set" && valueDigest(p.Value, true) != p.Digest:
|
||
return "its value is not the value its digest names: it changed after it was asked"
|
||
case p.Action == "clear" && (len(p.Value) != 0 || p.Digest != valueDigest(nil, false)):
|
||
return "a clear carries no value"
|
||
case valueDigest(p.Was, p.HadValue) != p.WasDigest:
|
||
return "the value it was shown against is not the one its digest names: it changed after it was asked"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ---- proposing -----------------------------------------------------------------------------------------
|
||
|
||
// ProposeAnswer is what a proposal answers.
|
||
type ProposeAnswer struct {
|
||
Said string `json:"said"`
|
||
Proposal string `json:"proposal"`
|
||
Asked string `json:"asked"`
|
||
Fingerprint string `json:"fingerprint"`
|
||
Expires time.Time `json:"expires"`
|
||
Router string `json:"router"`
|
||
Reaches []string `json:"reaches"`
|
||
Replaced []string `json:"replaced,omitempty"`
|
||
}
|
||
|
||
// AtTheTerminalInstead names the other way, said whenever a proposal cannot be asked (ADR 0277 §19).
|
||
const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead, through the controller's " +
|
||
"config line, which carries the value to mesh-config"
|
||
|
||
// approverReady says why no channel that proves who answered can carry an approval, when this module can tell
|
||
// from the channel bench's kinds; an empty list says nothing, and the router is the judge (ADR 0259 §4).
|
||
func approverReady(bus AskBus) string {
|
||
kinds, err := bus.Kinds()
|
||
if err != nil || len(kinds) == 0 {
|
||
return ""
|
||
}
|
||
channels := 0
|
||
for _, k := range kinds {
|
||
if k.Seat != asks.ChannelSeat {
|
||
continue
|
||
}
|
||
channels++
|
||
if k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") {
|
||
return ""
|
||
}
|
||
}
|
||
if channels == 0 {
|
||
return ""
|
||
}
|
||
return "no channel that proves who answered (one promising a verified sender, such as Telegram) is on the mesh"
|
||
}
|
||
|
||
func newProposalID() string {
|
||
var b [8]byte
|
||
_, _ = rand.Read(b[:])
|
||
return "cfg-" + hex.EncodeToString(b[:])
|
||
}
|
||
|
||
// endedWord is the router's word that an ask ended without a choice, heard while its proposer waits.
|
||
type endedWord struct {
|
||
Outcome asks.Outcome
|
||
Words string
|
||
}
|
||
|
||
func (c *Config) heardEnded(id string) (endedWord, bool) {
|
||
c.endedMu.Lock()
|
||
defer c.endedMu.Unlock()
|
||
w, ok := c.ended[id]
|
||
return w, ok
|
||
}
|
||
|
||
func (c *Config) tellEnded(id string, w endedWord) {
|
||
c.endedMu.Lock()
|
||
defer c.endedMu.Unlock()
|
||
if c.ended == nil {
|
||
c.ended = map[string]endedWord{}
|
||
}
|
||
c.ended[id] = w
|
||
}
|
||
|
||
// Propose judges a value as a set would, keeps it, and asks the operator under mesh-config's own name. Nothing is
|
||
// set here: only the operator's Approve sets it (Decide).
|
||
func (c *Config) Propose(call Call) (*ProposeAnswer, error) {
|
||
refuse := func(format string, a ...any) (*ProposeAnswer, error) {
|
||
return nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
|
||
}
|
||
if c.Asks == nil {
|
||
return refuse("the operator cannot be asked from here; %s", AtTheTerminalInstead)
|
||
}
|
||
if call.By != "" {
|
||
return refuse("by names who gave a value at the controller's terminal; a proposal is asked of the operator")
|
||
}
|
||
if call.Proposal != "" {
|
||
return refuse("proposal names a proposal to read, with action proposals")
|
||
}
|
||
action := "set"
|
||
if call.Clear {
|
||
action = "clear"
|
||
if call.Value != nil {
|
||
return refuse("a clear carries no value")
|
||
}
|
||
}
|
||
c.mu.Lock()
|
||
prop, ask, replaced, err := c.propose(call, action)
|
||
c.mu.Unlock()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
answer := &ProposeAnswer{Proposal: prop.ID, Asked: ask.Headline, Fingerprint: prop.Fingerprint(),
|
||
Expires: ask.Expires, Reaches: prop.Reaches, Replaced: replaced,
|
||
Router: "the router has not said yet whether it took the ask; config's proposals shows it"}
|
||
// The router's word, before answering (ADR 0277 §20): it refuses at once an ask no channel can carry.
|
||
within, every := c.RouterWithin, c.RouterEvery
|
||
if within <= 0 {
|
||
within = RouterAnswersWithin
|
||
}
|
||
if every <= 0 {
|
||
every = RouterAnswersEvery
|
||
}
|
||
for deadline := time.Now().Add(within); time.Now().Before(deadline); time.Sleep(every) {
|
||
if w, ended := c.heardEnded(prop.ID); ended {
|
||
return nil, fmt.Errorf("the router did not ask the operator: the ask %s (%s). The proposal is ended and "+
|
||
"nothing is set; %s", w.Outcome, w.Words, AtTheTerminalInstead)
|
||
}
|
||
if e, err := c.Asks.Record(prop.ID); err == nil && e != nil {
|
||
answer.Router = "the router took the ask and shows it on the channels that can carry it"
|
||
break
|
||
}
|
||
}
|
||
answer.Said = fmt.Sprintf("proposed %s of %s at %s (fingerprint %s): the operator is asked %q on a channel that "+
|
||
"proves who answered. Nothing changes until they approve; on Approve it takes effect at once, and on Decline "+
|
||
"or at %s UTC it is discarded", action, prop.Key, prop.Level, prop.Fingerprint(), ask.Headline,
|
||
ask.Expires.UTC().Format("15:04 on 2 Jan"))
|
||
return answer, nil
|
||
}
|
||
|
||
// propose is Propose's judgement, its record and its ask; the caller holds the lock.
|
||
func (c *Config) propose(call Call, action string) (Proposal, asks.Ask, []string, error) {
|
||
fail := func(format string, a ...any) (Proposal, asks.Ask, []string, error) {
|
||
return Proposal{}, asks.Ask{}, nil, errors.New(fmt.Sprintf(format, a...) + ". Nothing was proposed")
|
||
}
|
||
level, err := call.level()
|
||
if err != nil {
|
||
return fail("%v", err)
|
||
}
|
||
if !keyName.MatchString(call.Key) {
|
||
return fail("name the key to propose: lower-case letters, digits, dots, dashes and underscores")
|
||
}
|
||
why := strings.TrimSpace(call.Why)
|
||
if why == "" || strings.Contains(why, "\n") {
|
||
return fail("say why, in one line: every change keeps its why (novox/hq ADR 0348 §9)")
|
||
}
|
||
if call.Caller == "" || call.Caller == Unproven {
|
||
return fail("the call names no proven caller, and a proposal keeps who made it: call config through the mesh, " +
|
||
"which names you")
|
||
}
|
||
s, err := c.sources()
|
||
if err != nil {
|
||
return Proposal{}, asks.Ask{}, nil, err
|
||
}
|
||
// **Judged as a set judges it, before anyone is asked** (ADR 0277 §4): no ask for a value the verb would refuse.
|
||
r, trusted, err := c.declared(s, level, call.Key)
|
||
if err != nil {
|
||
return fail("%v", err)
|
||
}
|
||
vk := level.ValueKey(call.Key)
|
||
was, had := s.Values[vk]
|
||
now := c.now()
|
||
prop := Proposal{ID: newProposalID(), Action: action, Level: level, Key: call.Key, HadValue: had,
|
||
WasDigest: valueDigest(was.Value, had), Trusted: trusted, From: call.Caller, Why: why, At: now,
|
||
State: ProposalWaiting, Digest: valueDigest(nil, false)}
|
||
if had {
|
||
prop.Was = compact(was.Value)
|
||
}
|
||
after := Sources{Assignments: s.Assignments, Declared: s.Declared, Predefined: s.Predefined,
|
||
Values: map[string]Value{}}
|
||
for k, v := range s.Values {
|
||
after.Values[k] = v
|
||
}
|
||
switch action {
|
||
case "set":
|
||
if call.Value == nil {
|
||
return fail("give the value to propose for %s", call.Key)
|
||
}
|
||
raw, err := json.Marshal(call.Value)
|
||
if err != nil {
|
||
return fail("the value cannot be read: %v", err)
|
||
}
|
||
var value any
|
||
_ = json.Unmarshal(raw, &value)
|
||
if problems := r.check(value); len(problems) > 0 {
|
||
return fail("%s is refused for %s at %s: %s", show(value), call.Key, level, strings.Join(problems, "; "))
|
||
}
|
||
prop.Value, prop.Digest = compact(raw), valueDigest(raw, true)
|
||
if had && string(compact(was.Value)) == string(prop.Value) {
|
||
return fail("%s at %s already holds exactly %s", call.Key, level, prop.Value)
|
||
}
|
||
after.Values[vk] = Value{Value: prop.Value}
|
||
case "clear":
|
||
if !had {
|
||
return fail("nothing is set for %s at %s, so there is nothing to clear", call.Key, level)
|
||
}
|
||
delete(after.Values, vk)
|
||
}
|
||
for _, m := range diff(Resolve(s), Resolve(after)) {
|
||
prop.Reaches = append(prop.Reaches, m.Module+" on "+m.Node)
|
||
}
|
||
if prop.Reaches == nil {
|
||
prop.Reaches = []string{}
|
||
}
|
||
// **The operator is shown exactly what they approve** where who answered is proven (ADR 0277 §6, §19): a value
|
||
// shaped like a secret could be shown nowhere, so it is not proposed; config holds no secret.
|
||
if refusal, ok := outward.CheckSecret(prop.change(true)); !ok {
|
||
return fail("the change carries %s, which leaves the mesh nowhere, so the operator could not be shown what "+
|
||
"they approve; config holds no secret, and %s", refusal, AtTheTerminalInstead)
|
||
}
|
||
// **Its own cap of open asks, and the same change once** (ADR 0277 §21); a newer proposal for the same place
|
||
// replaces an older one still open (§10).
|
||
all, err := c.proposals()
|
||
if err != nil {
|
||
return Proposal{}, asks.Ask{}, nil, err
|
||
}
|
||
var replace []Proposal
|
||
open := 0
|
||
for _, o := range all {
|
||
if o.State != ProposalWaiting || now.After(o.At.Add(ProposalLasts)) {
|
||
continue
|
||
}
|
||
if o.about() == prop.about() {
|
||
if o.Action == prop.Action && o.Digest == prop.Digest && o.WasDigest == prop.WasDigest {
|
||
return fail("the same change is proposed already, as %s, and waits for the operator's answer", o.ID)
|
||
}
|
||
replace = append(replace, o)
|
||
continue
|
||
}
|
||
open++
|
||
}
|
||
if open >= asks.MostOpen {
|
||
return fail("%d proposals already wait for the operator's answer, and mesh-config asks at most %d at once; "+
|
||
"config's proposals lists them", open, asks.MostOpen)
|
||
}
|
||
if why := approverReady(c.Asks); why != "" {
|
||
return fail("%s; %s", why, AtTheTerminalInstead)
|
||
}
|
||
ask := prop.Ask()
|
||
if err := ask.Check(now); err != nil {
|
||
return fail("%v", err)
|
||
}
|
||
if err := c.Store.Put(ProposalsState, prop.ID, prop); err != nil {
|
||
return fail("the proposal could not be kept, so the operator was not asked: %v", err)
|
||
}
|
||
var replaced []string
|
||
for _, o := range replace {
|
||
o.State, o.Outcome, o.Decided = ProposalEnded, "replaced by the newer proposal "+prop.ID+"; nothing was set", now
|
||
if err := c.Store.Put(ProposalsState, o.ID, o); err == nil {
|
||
replaced = append(replaced, o.ID)
|
||
}
|
||
}
|
||
if err := c.Asks.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
||
prop.State, prop.Outcome = ProposalUnasked, "the ask could not be published: "+err.Error()
|
||
_ = c.Store.Put(ProposalsState, prop.ID, prop)
|
||
c.logf("[mesh-config] proposal %s could not be asked: %v", prop.ID, err)
|
||
return Proposal{}, asks.Ask{}, nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept "+
|
||
"and will never be set; %s", err, AtTheTerminalInstead)
|
||
}
|
||
c.logf("[mesh-config] proposal %s (%s %s at %s, fingerprint %s) asked of the operator", prop.ID, action, prop.Key,
|
||
prop.Level, prop.Fingerprint())
|
||
return prop, ask, replaced, nil
|
||
}
|
||
|
||
// proposals is every proposal kept, oldest first.
|
||
func (c *Config) proposals() ([]Proposal, error) {
|
||
var out []Proposal
|
||
err := c.each(ProposalsState, func(key string, raw []byte) error {
|
||
var p Proposal
|
||
if json.Unmarshal(raw, &p) != nil || p.ID != key {
|
||
return nil // not one this module wrote: never asked, never acted on
|
||
}
|
||
out = append(out, p)
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, fmt.Errorf("the proposals cannot be read: %w", err)
|
||
}
|
||
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
|
||
return out, nil
|
||
}
|
||
|
||
// ---- deciding --------------------------------------------------------------------------------------------
|
||
|
||
// routerRecord is the router's record of one ask, as far as this module reads it.
|
||
type routerRecord struct {
|
||
State string `json:"state"`
|
||
Ask *asks.Ask `json:"ask"`
|
||
Warrant *asks.Warrant `json:"warrant"`
|
||
}
|
||
|
||
// Decide decides every waiting proposal whose ask the router has closed, oldest warrant first, each held to the
|
||
// warrant as described at the top of this file; and asks the controller to record every warrant acted on that its
|
||
// hand-act log does not hold yet. It answers a line for each decision.
|
||
func (c *Config) Decide() ([]string, error) {
|
||
if c.Asks == nil {
|
||
return nil, nil
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
all, err := c.proposals()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := c.now()
|
||
type closed struct {
|
||
p Proposal
|
||
w asks.Warrant
|
||
}
|
||
var ready []closed
|
||
var said []string
|
||
for _, p := range all {
|
||
if p.State != ProposalWaiting {
|
||
continue
|
||
}
|
||
e, err := c.Asks.Record(p.ID)
|
||
if err == nil && e == nil && now.After(p.At.Add(ProposalLasts+time.Hour)) {
|
||
// Ended, and the router keeps no record of it: no answer can be proven, so nothing is set.
|
||
said = append(said, c.end(p, "the router keeps no record of its ask (refused before it was taken, or "+
|
||
"forgotten); nothing was set"))
|
||
continue
|
||
}
|
||
if err != nil || e == nil {
|
||
continue // open, not yet recorded, or not readable now: read again on the next look
|
||
}
|
||
var rec routerRecord
|
||
if json.Unmarshal(e.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
||
continue
|
||
}
|
||
ready = append(ready, closed{p, *rec.Warrant})
|
||
}
|
||
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
|
||
for _, r := range ready {
|
||
said = append(said, c.decide(r.p, r.w))
|
||
}
|
||
c.recordWarrants(now)
|
||
return said, nil
|
||
}
|
||
|
||
// end records a proposal ended without the operator's choice.
|
||
func (c *Config) end(p Proposal, why string) string {
|
||
p.State, p.Outcome, p.Decided = ProposalEnded, why, c.now()
|
||
if err := c.Store.Put(ProposalsState, p.ID, p); err != nil {
|
||
return fmt.Sprintf("proposal %s: %s, and that could not be kept (%v)", p.ID, why, err)
|
||
}
|
||
return "proposal " + p.ID + ": " + why
|
||
}
|
||
|
||
// decide holds one closed ask's warrant to its proposal and, on an Approve that holds, sets the value. The caller
|
||
// holds the lock.
|
||
func (c *Config) decide(p Proposal, w asks.Warrant) string {
|
||
now := c.now()
|
||
keep := func(state, outcome string) string {
|
||
p.State, p.Outcome, p.Decided = state, outcome, now
|
||
if err := c.Store.Put(ProposalsState, p.ID, p); err != nil {
|
||
return fmt.Sprintf("proposal %s: %s, and that could not be kept (%v)", p.ID, outcome, err)
|
||
}
|
||
return "proposal " + p.ID + ": " + outcome
|
||
}
|
||
refused := func(why string) string {
|
||
c.logf("[mesh-config] REFUSED the answer to proposal %s: %s", p.ID, why)
|
||
return keep(ProposalRefused, "the answer is refused and nothing is set: "+why)
|
||
}
|
||
if w.Asker != Module || w.Ask != p.ID {
|
||
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
|
||
}
|
||
if w.Outcome != asks.OutcomeChosen {
|
||
why := "the ask " + string(w.Outcome)
|
||
if w.Words != "" {
|
||
why += " (" + w.Words + ")"
|
||
}
|
||
return keep(ProposalEnded, why+"; nothing was set")
|
||
}
|
||
// **The record is held to what was asked** (ADR 0277 §11, §12): its value and the value it was shown against
|
||
// still digest to what the ask bound, the warrant answers the ask composed again from it, and the act about to be
|
||
// performed is the one the chosen option bound.
|
||
if problem := p.problem(); problem != "" {
|
||
return refused("the proposal is not the one asked: " + problem)
|
||
}
|
||
option, err := w.For(Module, p.Ask())
|
||
if err == nil {
|
||
err = option.Performs(p.Act(option.ID))
|
||
}
|
||
if err != nil {
|
||
return refused(err.Error())
|
||
}
|
||
switch option.ID {
|
||
case OptionDecline:
|
||
p.Answered = w.Says()
|
||
return keep(ProposalDeclined, "declined by the operator ("+w.Says()+"); nothing was set")
|
||
case OptionApprove:
|
||
p.Answered, p.Approved = w.Says(), approvedWords(p, w)
|
||
default:
|
||
return refused("the option " + option.ID + " is not one a proposal offers")
|
||
}
|
||
// Claimed before it is acted on: a warrant heard twice, or read again, sets nothing twice.
|
||
p.State, p.Outcome, p.Decided = ProposalApproved, "approved by the operator; being set", now
|
||
if err := c.Store.Put(ProposalsState, p.ID, p); err != nil {
|
||
return fmt.Sprintf("proposal %s: approved by the operator, and not set: the decision could not be kept (%v); "+
|
||
"it is read again on the next look", p.ID, err)
|
||
}
|
||
call := Call{Action: p.Action, Key: p.Key, Module: p.Level.Module, Node: p.Level.Node, Why: p.Why, Caller: p.From,
|
||
approval: &approval{Proposal: p.ID, By: "the operator", Says: p.Approved, Was: p.Was, HadValue: p.HadValue,
|
||
WasDigest: p.WasDigest}}
|
||
if p.Action == "set" {
|
||
var v any
|
||
_ = json.Unmarshal(p.Value, &v)
|
||
call.Value = v
|
||
if v == nil {
|
||
call.Value = json.RawMessage("null")
|
||
}
|
||
}
|
||
a, err := c.change(call)
|
||
if err != nil {
|
||
return keep(ProposalRefused, "approved by the operator, and refused on Approve, so nothing is set: "+err.Error())
|
||
}
|
||
return keep(ProposalApproved, "approved by the operator and set: "+a.Said)
|
||
}
|
||
|
||
// approvedWords is who approved it, as kept beside the value (ADR 0277 §15).
|
||
func approvedWords(p Proposal, w asks.Warrant) string {
|
||
who := "the operator"
|
||
if w.By != nil {
|
||
who = "the " + w.By.Who
|
||
if w.By.Identity != "" {
|
||
who += ", as " + w.By.Kind + " identity " + w.By.Identity
|
||
}
|
||
via := w.By.Kind
|
||
if w.By.Verified != "" {
|
||
via += " (" + w.By.Verified + ")"
|
||
}
|
||
who += ", via " + via
|
||
}
|
||
return fmt.Sprintf("approved by %s at %s, ask %s, proposed by %s", who, w.At.UTC().Format(time.RFC3339), p.ID, p.From)
|
||
}
|
||
|
||
// recordWarrants asks the controller to record each warrant acted on (Approve or Decline) that its hand-act log
|
||
// does not hold yet (ADR 0277 §17): the controller reads it from the router's own record. The caller holds the lock.
|
||
func (c *Config) recordWarrants(now time.Time) {
|
||
all, err := c.proposals()
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, p := range all {
|
||
if p.Recorded || p.Answered == "" || now.Sub(p.Decided) > 7*24*time.Hour {
|
||
continue
|
||
}
|
||
if err := c.Asks.Warranted(p.ID); err != nil {
|
||
p.Record = "not recorded in the hand-act log yet: " + err.Error()
|
||
} else {
|
||
p.Recorded, p.Record = true, ""
|
||
}
|
||
_ = c.Store.Put(ProposalsState, p.ID, p)
|
||
}
|
||
}
|
||
|
||
// HeardEnd takes the router's word on one of this module's asks. An ask ended without a choice ends its proposal
|
||
// at once, and is told to a proposer waiting on it; nothing is set on such a word, so it needs no proof beyond its
|
||
// subject. A choice is only ever taken from the router's record (Decide).
|
||
func (c *Config) HeardEnd(w asks.Warrant) []string {
|
||
if w.Asker != Module || !asks.UsableID(w.Ask) {
|
||
return nil
|
||
}
|
||
if w.Outcome == asks.OutcomeChosen {
|
||
said, err := c.Decide()
|
||
if err != nil {
|
||
said = append(said, "reading the operator's answers: "+err.Error())
|
||
}
|
||
return said
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
defer c.tellEnded(w.Ask, endedWord{Outcome: w.Outcome, Words: w.Words})
|
||
e, err := c.Store.Get(ProposalsState, w.Ask)
|
||
if err != nil || e == nil {
|
||
return nil
|
||
}
|
||
var p Proposal
|
||
if json.Unmarshal(e.Value, &p) != nil || p.ID != w.Ask || p.State != ProposalWaiting {
|
||
return nil
|
||
}
|
||
why := "the ask " + string(w.Outcome)
|
||
if w.Words != "" {
|
||
why += " (" + w.Words + ")"
|
||
}
|
||
return []string{c.end(p, why+"; nothing was set")}
|
||
}
|
||
|
||
// ---- the list ----------------------------------------------------------------------------------------------
|
||
|
||
// ProposalRow is one proposal as a listing says it.
|
||
type ProposalRow struct {
|
||
ID string `json:"id"`
|
||
Action string `json:"action"`
|
||
Level Level `json:"level"`
|
||
Key string `json:"key"`
|
||
State string `json:"state"`
|
||
Outcome string `json:"outcome,omitempty"`
|
||
From string `json:"from"`
|
||
At time.Time `json:"at"`
|
||
Expires time.Time `json:"expires"`
|
||
Fingerprint string `json:"fingerprint"`
|
||
Approved string `json:"approved,omitempty"`
|
||
// What one proposal read whole says besides.
|
||
Value json.RawMessage `json:"value,omitempty"`
|
||
Was json.RawMessage `json:"was,omitempty"`
|
||
Why string `json:"why,omitempty"`
|
||
Reaches []string `json:"reaches,omitempty"`
|
||
Router string `json:"router,omitempty"`
|
||
Phone string `json:"the phone,omitempty"`
|
||
Warning string `json:"warning,omitempty"`
|
||
}
|
||
|
||
// Proposals is every proposal, newest first; with a proposal's id, that one whole, with whether the router's ask
|
||
// is the one composed from it.
|
||
func (c *Config) Proposals(call Call) ([]ProposalRow, error) {
|
||
all, err := c.proposals()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := []ProposalRow{}
|
||
for i := len(all) - 1; i >= 0; i-- {
|
||
p := all[i]
|
||
if call.Proposal != "" && p.ID != call.Proposal {
|
||
continue
|
||
}
|
||
if (call.Key != "" && p.Key != call.Key) || (call.Node != "" && p.Level.Node != call.Node) ||
|
||
(call.Module != "" && p.Level.Module != call.Module) {
|
||
continue
|
||
}
|
||
row := ProposalRow{ID: p.ID, Action: p.Action, Level: p.Level, Key: p.Key, State: p.State, Outcome: p.Outcome,
|
||
From: p.From, At: p.At, Expires: p.At.Add(ProposalLasts), Fingerprint: p.Fingerprint(), Approved: p.Approved}
|
||
if p.State == ProposalWaiting && c.now().After(row.Expires) {
|
||
row.State = "expired, not yet heard"
|
||
}
|
||
if call.Proposal != "" {
|
||
row.Value, row.Was, row.Why, row.Reaches = p.Value, p.Was, p.Why, p.Reaches
|
||
if problem := p.problem(); problem != "" {
|
||
row.Warning = "this proposal is never set: " + problem
|
||
}
|
||
if c.Asks != nil {
|
||
if e, err := c.Asks.Record(p.ID); err == nil && e != nil {
|
||
var rec routerRecord
|
||
if json.Unmarshal(e.Value, &rec) == nil {
|
||
row.Router = rec.State
|
||
switch {
|
||
case rec.Ask == nil:
|
||
row.Phone = "cannot be compared: the router's record keeps no ask"
|
||
case rec.Ask.Digest() == p.Ask().Digest():
|
||
row.Phone = "shows the ask composed from exactly this proposal (fingerprint " + p.Fingerprint() + ")"
|
||
default:
|
||
row.Phone = "shows ANOTHER ask than this proposal composes: do not approve it"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out = append(out, row)
|
||
}
|
||
if call.Proposal != "" && len(out) == 0 {
|
||
return nil, errors.New("no proposal " + strconv.Quote(call.Proposal) + " is kept")
|
||
}
|
||
return out, nil
|
||
}
|