mesh/merge-gate pass: builds claude-code → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
A refusal heard on the decided subject is kept as heard, and a record of a person's choice found later is still taken, so an ask the router's queue offered again is not lost in silence. A heard end must match the proposal's ask; the wait stops reading at its deadline; the heard ends are forgotten when Propose returns.
1130 lines
45 KiB
Go
1130 lines
45 KiB
Go
package main
|
|
|
|
// Shared instructions on the operator's approval (novox/hq ADR 0274, amending issue 340's decision 2).
|
|
//
|
|
// An agent may PROPOSE an instruction section for every Claude session on the mesh, or on some nodes: a rule,
|
|
// a hand-over, the context a later session needs. A proposal is text and nothing else. It is kept in the
|
|
// module's `proposals` state and asked of the operator through the operator channel (novox/hq ADR 0259) 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) can carry either. Nothing is active until the operator approves.
|
|
//
|
|
// **Every node proves the approval itself.** The `proposals` state is written by every node's tool runner, so a
|
|
// record there proves nothing. Each instance of this module, for each proposal it has not decided yet:
|
|
//
|
|
// 1. reads the router's own record of the ask, under this module's name (the bus lets only the router write it,
|
|
// and lets this module read only its own asks);
|
|
// 2. composes the ask again from the proposal — the words, the options, the expiry, the digest of the text —
|
|
// and holds the router's warrant to it: the same ask digest, an option it offered, at its level, in time
|
|
// (asks.Warrant.For), and the option's bound act is this proposal's (asks.Option.Performs). An ask published
|
|
// under this module's name with other words, or a proposal changed after it was asked, finds the digests
|
|
// differ and nothing is done;
|
|
// 3. applies the approved text to its own file, `approved.json` in the module's state directory, which is what
|
|
// the managed instruction file renders from. A proposal decided here is never decided again: a warrant heard
|
|
// twice, or replayed, changes nothing.
|
|
//
|
|
// Removing an approved section is a proposal too, approved the same way: a record deleted from the bus removes
|
|
// nothing, because the node's own file is what renders.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// Module is this module's name: the one the bus grants its asks under.
|
|
const Module = "claude-code"
|
|
|
|
// ProposalsState is the module's state the proposals are kept in.
|
|
const ProposalsState = "proposals"
|
|
|
|
// MaxProposalBytes is the most one proposed text may hold: it is read by every session it reaches, every time.
|
|
const MaxProposalBytes = 16 << 10
|
|
|
|
// RecordFor and RecordEvery bound how long, and how often, a node asks the controller to record a warrant it
|
|
// decided while the controller has not taken it.
|
|
const (
|
|
RecordFor = 7 * 24 * time.Hour
|
|
RecordEvery = 10 * time.Minute
|
|
)
|
|
|
|
// RouterKeeps is how long the router keeps its record of an ask after it closed (novox/hq ADR 0259 §3): a node
|
|
// that has not read it by then cannot prove the answer.
|
|
const RouterKeeps = 30 * 24 * time.Hour
|
|
|
|
// ProposalLasts is how long the operator has to answer: a day less a margin, so the router never refuses it for
|
|
// lasting a day and a moment (the SDK's bound for an approval).
|
|
const ProposalLasts = asks.ApproveLasts - 10*time.Minute
|
|
|
|
// What a proposal does.
|
|
const (
|
|
ActionAdd = "add"
|
|
ActionReplace = "replace"
|
|
ActionRemove = "remove"
|
|
)
|
|
|
|
// The two answers, both at the level approve.
|
|
const (
|
|
OptionApprove = "approve"
|
|
OptionDecline = "decline"
|
|
)
|
|
|
|
// How much of the text the operator's phone is shown: all of it when it is short and every line may leave the
|
|
// mesh, else its first lines.
|
|
const (
|
|
WholeTextBytes = 2000
|
|
WholeTextLines = 40
|
|
FirstBytes = 1200
|
|
FirstLines = 20
|
|
)
|
|
|
|
// Proposal is one proposed change to the shared instructions, as the `proposals` state keeps it. Every field the
|
|
// ask is composed from is here, so any node composes the same ask from it.
|
|
type Proposal struct {
|
|
ID string `json:"id"`
|
|
Action string `json:"action"`
|
|
Scope string `json:"scope"`
|
|
Nodes []string `json:"nodes,omitempty"`
|
|
Name string `json:"name"`
|
|
Text string `json:"text,omitempty"`
|
|
Digest string `json:"digest"`
|
|
From string `json:"from"`
|
|
At time.Time `json:"at"`
|
|
// Asked is empty once the ask was published, or says why it was not; nothing composes from it.
|
|
Asked string `json:"asked,omitempty"`
|
|
}
|
|
|
|
// textDigest is the digest a proposal binds: SHA-256 over the text's bytes.
|
|
func textDigest(text string) string {
|
|
sum := sha256.Sum256([]byte(text))
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// Fingerprint is the 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 (p Proposal) Fingerprint() string { return fingerprintOf(p.Digest) }
|
|
|
|
func fingerprintOf(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, " ")
|
|
}
|
|
|
|
// Lines is how many lines the text has.
|
|
func (p Proposal) Lines() int {
|
|
if p.Text == "" {
|
|
return 0
|
|
}
|
|
return len(strings.Split(strings.TrimRight(p.Text, "\n"), "\n"))
|
|
}
|
|
|
|
// where says which sessions a proposal reaches, in words.
|
|
func (p Proposal) where() string {
|
|
if p.Scope == ScopeMesh {
|
|
return "every Claude session on the mesh"
|
|
}
|
|
return "every Claude session on " + joinAnd(p.Nodes)
|
|
}
|
|
|
|
func joinAnd(list []string) string {
|
|
switch len(list) {
|
|
case 0:
|
|
return ""
|
|
case 1:
|
|
return list[0]
|
|
}
|
|
return strings.Join(list[:len(list)-1], ", ") + " and " + list[len(list)-1]
|
|
}
|
|
|
|
// Act is what an answer binds: this proposal, whole, and the answer. The router's warrant names the ask's digest,
|
|
// and the ask names this act's digest in the option, so an approval authorises this text and nothing else.
|
|
func (p Proposal) Act(option string) asks.Act {
|
|
return asks.Act{"verb": Module + ".instructions", "option": option, "proposal": p.ID, "action": p.Action,
|
|
"scope": p.Scope, "nodes": strings.Join(p.Nodes, ","), "name": p.Name, "digest": p.Digest, "from": p.From,
|
|
"at": p.At.UTC().Format(time.RFC3339Nano)}
|
|
}
|
|
|
|
// Ask is the ask for a proposal, composed from it alone: every node composes the same one, and holds the
|
|
// router's warrant to it.
|
|
func (p Proposal) Ask() asks.Ask {
|
|
verb := map[string]string{ActionAdd: "Add", ActionReplace: "Replace", ActionRemove: "Remove"}[p.Action]
|
|
shown, whole := excerpt(p.Text)
|
|
part := ""
|
|
if p.Action != ActionRemove && !whole {
|
|
part = " Part shown"
|
|
}
|
|
headline := fmt.Sprintf("%s Claude instructions '%s'?%s", verb, p.Name, part)
|
|
if len([]rune(headline)) > asks.HeadlineLength {
|
|
headline = verb + " Claude instructions?" + part
|
|
}
|
|
var b strings.Builder
|
|
switch p.Action {
|
|
case ActionAdd:
|
|
fmt.Fprintf(&b, "Add instructions '%s' to %s?\n\n", p.Name, p.where())
|
|
case ActionReplace:
|
|
fmt.Fprintf(&b, "Replace the instructions '%s' in %s?\n\n", p.Name, p.where())
|
|
case ActionRemove:
|
|
fmt.Fprintf(&b, "Remove the instructions '%s' from %s?\n\n", p.Name, p.where())
|
|
}
|
|
fmt.Fprintf(&b, "An agent proposed this through the claude-code module on %s, at %s UTC.", p.From,
|
|
p.At.UTC().Format("15:04 on 2 Jan"))
|
|
if p.Action == ActionRemove {
|
|
fmt.Fprintf(&b, " The approved text it removes has the fingerprint %s.", p.Fingerprint())
|
|
} else {
|
|
fmt.Fprintf(&b, " The text has %d lines, %d bytes, fingerprint %s. Once approved, every new session there "+
|
|
"reads it as the mesh's own word until it is removed the same way.\n\n", p.Lines(), len(p.Text), p.Fingerprint())
|
|
if whole {
|
|
b.WriteString("The whole text:\n\n" + shown)
|
|
} else {
|
|
fmt.Fprintf(&b, "NOT SHOWN IN FULL here. Its first lines:\n\n%s\n\nRead all of it before you answer, at the controller's terminal: "+
|
|
"mesh-cli ask claude-code claude_code_proposals '{\"id\": \"%s\"}'. The fingerprint there must be %s.",
|
|
shown, p.ID, p.Fingerprint())
|
|
}
|
|
}
|
|
does := "the text becomes part of those sessions' instructions"
|
|
if p.Action == ActionRemove {
|
|
does = "the instructions are removed from those sessions"
|
|
}
|
|
a := asks.Ask{ID: p.ID, Headline: headline, Explanation: strings.TrimRight(b.String(), "\n"), Who: asks.Operator,
|
|
Expires: p.At.Add(ProposalLasts), OnExpiry: "the proposal is discarded; nothing changes",
|
|
About: "instructions." + p.Scope + "." + strings.Join(p.Nodes, "+") + "." + p.Name}
|
|
if len(p.Nodes) == 0 {
|
|
a.About = "instructions." + p.Scope + "." + p.Name
|
|
}
|
|
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
|
|
}
|
|
|
|
// excerpt is what of a text the operator's phone is shown, and whether it is the whole text. A line that might
|
|
// carry what may not leave the mesh (a path, an address, a long unbroken word) is not shown, and a marker says
|
|
// so; the router refuses an ask whose words carry one, and the whole text is read at the terminal.
|
|
func excerpt(text string) (string, bool) {
|
|
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
|
|
whole := len(text) <= WholeTextBytes && len(lines) <= WholeTextLines
|
|
limitBytes, limitLines := FirstBytes, FirstLines
|
|
if whole {
|
|
limitBytes, limitLines = WholeTextBytes, WholeTextLines
|
|
}
|
|
var out []string
|
|
size, hidden := 0, 0
|
|
flush := func() {
|
|
if hidden > 0 {
|
|
out = append(out, fmt.Sprintf("[%d line(s) not shown here: a path, an address or a long word]", hidden))
|
|
hidden = 0
|
|
}
|
|
}
|
|
for i, line := range lines {
|
|
if i >= limitLines || size+len(line) > limitBytes {
|
|
whole = false
|
|
break
|
|
}
|
|
if !shownLine(line) {
|
|
whole = false
|
|
hidden++
|
|
continue
|
|
}
|
|
flush()
|
|
out = append(out, line)
|
|
size += len(line) + 1
|
|
}
|
|
flush()
|
|
return strings.Join(out, "\n"), whole
|
|
}
|
|
|
|
var (
|
|
unbroken = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`)
|
|
hexRun = regexp.MustCompile(`(?i)[0-9a-f]{16,}`)
|
|
numbersAt = regexp.MustCompile(`\d+\.\d+\.\d+`)
|
|
hostLike = regexp.MustCompile(`(?i)\b[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,12}\b`)
|
|
assigned = regexp.MustCompile(`(?i)(password|passwd|passphrase|secret|token|api[_-]?key|credential|private[_-]?key)\s*[=:]`)
|
|
)
|
|
|
|
// shownLine says whether a line may be shown on the phone: no path, address, host-like name, long unbroken word
|
|
// or value given to a secret's name. Deliberately narrower than the router's own rule, so an ask is rarely
|
|
// refused for its words; a line kept back is read at the terminal.
|
|
func shownLine(line string) bool {
|
|
if strings.ContainsAny(line, "/\\@") || strings.Contains(line, "~") || strings.Contains(line, "$") {
|
|
return false
|
|
}
|
|
for _, re := range []*regexp.Regexp{unbroken, hexRun, numbersAt, assigned} {
|
|
if re.MatchString(line) {
|
|
return false
|
|
}
|
|
}
|
|
// A sentence's end ("word. Next") never matches, nor does "e.g."; anything else shaped like a.name is held back.
|
|
return !hostLike.MatchString(line)
|
|
}
|
|
|
|
// anyImport is an @ the agent's reader could take for a file to read in: one at a text's start or after anything that
|
|
// is not a letter or a digit — any whitespace, Unicode's included, or punctuation. Wider than the vendor's own
|
|
// pattern on purpose: an e-mail-like word is the one kind let through.
|
|
var anyImport = regexp.MustCompile(`(?:^|[^\p{L}\p{N}])@\S`)
|
|
|
|
// ownHeading is a heading of level one to three, the levels the managed instruction file's own sections use.
|
|
var ownHeading = regexp.MustCompile(`(?m)^[ \t]{0,3}#{1,3}([ \t]|$)[^\n]*`)
|
|
|
|
// setextHeading is a non-blank line underlined with = or -; htmlHeading an HTML heading of level one to three.
|
|
var (
|
|
setextHeading = regexp.MustCompile(`(?m)^[ \t]{0,3}\S[^\n]*\n[ \t]{0,3}(=+|-+)[ \t]*$`)
|
|
htmlHeading = regexp.MustCompile(`(?i)<h[1-3][\s>]`)
|
|
)
|
|
|
|
// invisible names the first character of a text a reader would not see and a session would read, or "": a
|
|
// control character but a line break and a tab, a format character (zero-width, bidirectional overrides, the
|
|
// tag block), private use, a noncharacter, and any space or separator but the plain space.
|
|
func invisible(text string) string {
|
|
for _, r := range text {
|
|
switch {
|
|
case r == '\n' || r == '\t' || r == ' ':
|
|
continue
|
|
case unicode.Is(unicode.Cc, r):
|
|
return fmt.Sprintf("a control character (U+%04X)", r)
|
|
case unicode.Is(unicode.Cf, r):
|
|
return fmt.Sprintf("an invisible format character (U+%04X)", r)
|
|
case (r >= 0xFE00 && r <= 0xFE0F) || (r >= 0xE0100 && r <= 0xE01EF):
|
|
return fmt.Sprintf("a variation selector (U+%04X)", r)
|
|
case r == 0x3164 || r == 0x115F || r == 0x1160 || r == 0xFFA0 || r == 0x2800:
|
|
return fmt.Sprintf("a blank letter (U+%04X)", r)
|
|
case unicode.Is(unicode.Co, r):
|
|
return fmt.Sprintf("a private-use character (U+%04X)", r)
|
|
case unicode.Is(unicode.Zs, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r):
|
|
return fmt.Sprintf("a space or separator other than the plain space (U+%04X)", r)
|
|
case r == utf8.RuneError || (r >= 0xFDD0 && r <= 0xFDEF) || r&0xFFFE == 0xFFFE:
|
|
return fmt.Sprintf("a noncharacter (U+%04X)", r)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ProposalProblem says why a proposal, as kept on the bus, is not one this module would have made, or "": every
|
|
// node judges it again before it applies an approval, because anything holding a tool runner's credential can
|
|
// write the record and ask for it under this module's name.
|
|
func ProposalProblem(p Proposal) string {
|
|
switch {
|
|
case !asks.UsableID(p.ID):
|
|
return "its id is not one"
|
|
case !itemName.MatchString(p.Name):
|
|
return "its name is not one"
|
|
case !nodeName.MatchString(p.From):
|
|
return "where it came from is not a node's name"
|
|
case p.At.IsZero():
|
|
return "it says no time"
|
|
case p.Scope == ScopeMesh && len(p.Nodes) > 0:
|
|
return "the mesh scope names no nodes"
|
|
case p.Scope == ScopeNode && len(p.Nodes) == 0:
|
|
return "the node scope names its nodes"
|
|
case p.Scope != ScopeMesh && p.Scope != ScopeNode:
|
|
return "its scope is neither mesh nor node"
|
|
}
|
|
if p.Scope == ScopeNode {
|
|
if problem := nodesProblem(p.Nodes); problem != "" {
|
|
return problem
|
|
}
|
|
for i := 1; i < len(p.Nodes); i++ {
|
|
if p.Nodes[i-1] >= p.Nodes[i] {
|
|
return "its nodes are not listed once each, in order"
|
|
}
|
|
}
|
|
}
|
|
switch p.Action {
|
|
case ActionAdd, ActionReplace:
|
|
if problem := ProposedTextProblem(p.Text); problem != "" {
|
|
return problem
|
|
}
|
|
if textDigest(p.Text) != p.Digest {
|
|
return "its text is not the text its digest names"
|
|
}
|
|
case ActionRemove:
|
|
if p.Text != "" || !strings.HasPrefix(p.Digest, "sha256:") {
|
|
return "a removal carries no text, and the digest of the text it removes"
|
|
}
|
|
default:
|
|
return "its action is not add, replace or remove"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// secretShaped are shapes a shared text never carries: it is read by every session and shown on a phone.
|
|
var secretShaped = []*regexp.Regexp{
|
|
regexp.MustCompile(`-----BEGIN [A-Z ]+-----`),
|
|
regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`),
|
|
regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`),
|
|
regexp.MustCompile(`(?i)\b(password|passwd|passphrase|token|api[_-]?key|apikey|private[_-]?key)\s*[=:]\s*\S`),
|
|
}
|
|
|
|
// ProposedTextProblem says why a text cannot be proposed, or "": empty, too long, not text, or something a
|
|
// session would run or read in by itself (issue 340's refusals, kept): a shell line, front matter, a hook, a tool
|
|
// server, a permission mode or pre-approved tools, or an `@` reference, which the instruction file reads in as a
|
|
// file; or anything shaped like a secret.
|
|
func ProposedTextProblem(text string) string {
|
|
switch {
|
|
case strings.TrimSpace(text) == "":
|
|
return "the text is empty"
|
|
case len(text) > MaxProposalBytes:
|
|
return fmt.Sprintf("the text is %d bytes, more than the %d a shared section may hold: every session reads all of it", len(text), MaxProposalBytes)
|
|
case !utf8.ValidString(text) || strings.ContainsRune(text, 0):
|
|
return "the text is not text"
|
|
case invisible(text) != "":
|
|
return "the text holds " + invisible(text) + ", which a phone does not show and a session reads; a shared section is plain visible text"
|
|
case shellLine.MatchString(text):
|
|
return "the text holds a shell line (!`…` or ```!), which a session may run by itself; nothing proposed here runs (novox/hq issue 340)"
|
|
}
|
|
if keys, problem := frontMatterKeys(text); problem != "" || len(keys) > 0 {
|
|
for _, key := range keys {
|
|
norm := strings.ToLower(strings.NewReplacer("-", "", "_", "").Replace(key))
|
|
if why, ok := runsFrontMatter[norm]; ok {
|
|
return fmt.Sprintf("the text's front matter says %q: %s; nothing proposed here runs or loosens what runs (novox/hq issue 340)", key, why)
|
|
}
|
|
}
|
|
return "the text opens with front matter, which a session may act on; a shared section is plain text (novox/hq issue 340)"
|
|
}
|
|
if m := anyImport.FindString(text); m != "" {
|
|
return fmt.Sprintf("the text holds %q: an @ after anything but a letter or a digit may be read in as a file, "+
|
|
"and a shared section refers to no file (novox/hq issue 340)", strings.TrimSpace(m))
|
|
}
|
|
if m := setextHeading.FindString(text); m != "" {
|
|
return "the text underlines a line with = or -, which makes it a heading that would read as a section of the mesh's own"
|
|
}
|
|
if m := htmlHeading.FindString(text); m != "" {
|
|
return fmt.Sprintf("the text holds %q, a heading that would read as a section of the mesh's own", m)
|
|
}
|
|
if m := ownHeading.FindString(text); m != "" {
|
|
return fmt.Sprintf("the text holds the heading %q, which would read as a section of the mesh's own; a shared "+
|
|
"section uses headings of level four or deeper", strings.TrimSpace(m))
|
|
}
|
|
for _, re := range secretShaped {
|
|
if re.MatchString(text) {
|
|
return "the text holds something shaped like a secret, which a shared section never carries"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ---- what this node approved ------------------------------------------------------------------------
|
|
|
|
// ApprovedSection is one approved section as this node keeps it.
|
|
type ApprovedSection struct {
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
Nodes []string `json:"nodes,omitempty"`
|
|
Text string `json:"text"`
|
|
Digest string `json:"digest"`
|
|
Proposal string `json:"proposal"`
|
|
From string `json:"from"`
|
|
Approved time.Time `json:"approved"`
|
|
By string `json:"by"`
|
|
}
|
|
|
|
// ProposalDecision is what this node made of one proposal: once decided, never again.
|
|
type ProposalDecision struct {
|
|
Outcome string `json:"outcome"`
|
|
At time.Time `json:"at"`
|
|
Says string `json:"says,omitempty"`
|
|
// Recorded says the controller's hand-act log holds the warrant; Record is why not, while it does not.
|
|
// Warranted says a person's choice held here: Approve or Decline, recorded in the hand-act log.
|
|
Warranted bool `json:"warranted,omitempty"`
|
|
Recorded bool `json:"recorded,omitempty"`
|
|
// Heard says the decision came from the router's word on the decided subject, not its record: a record of a
|
|
// choice found later is still taken.
|
|
Heard bool `json:"heard,omitempty"`
|
|
Record string `json:"record,omitempty"`
|
|
LastTry time.Time `json:"last-try,omitempty"`
|
|
}
|
|
|
|
// Approved is this node's own record: the sections it renders, and every proposal it decided.
|
|
type Approved struct {
|
|
Sections map[string]ApprovedSection `json:"sections"`
|
|
Decided map[string]ProposalDecision `json:"decided"`
|
|
// Changed is, for each section's place, when the operator approved the last change applied to it here — an
|
|
// addition, a replacement or a removal. An approval older than it is never applied: an earlier approval
|
|
// replayed, or read late, does not undo a later one.
|
|
Changed map[string]time.Time `json:"changed,omitempty"`
|
|
// Refused is the proposals whose answer did not hold here, and why: asked again on every look, so an honest
|
|
// approval refused for a reason that passes (a node still on an older version) is taken when it holds.
|
|
Refused map[string]string `json:"refused,omitempty"`
|
|
}
|
|
|
|
func (p Paths) approved() string { return filepath.Join(p.State, "approved.json") }
|
|
|
|
// sectionKey is one section's place: its scope, the nodes it is for and its name, so a section for some nodes is
|
|
// never replaced or removed by a proposal for other nodes.
|
|
func sectionKey(scope string, nodes []string, name string) string {
|
|
return scope + "/" + strings.Join(nodes, ",") + "/" + name
|
|
}
|
|
|
|
// ReadApproved is this node's record, or an empty one when there is none yet. A record that cannot be read is
|
|
// an error, never taken for an empty one: that would forget every decision and open them all again.
|
|
func ReadApproved(p Paths) (Approved, error) {
|
|
var a Approved
|
|
raw, err := fsys.ReadFile(p.approved())
|
|
switch {
|
|
case errors.Is(err, os.ErrNotExist):
|
|
case err != nil:
|
|
return a, fmt.Errorf("this node's record of what the operator approved (%s) cannot be read: %w", p.approved(), err)
|
|
default:
|
|
if err := json.Unmarshal(raw, &a); err != nil {
|
|
return a, fmt.Errorf("this node's record of what the operator approved (%s) is not readable JSON: %w; "+
|
|
"nothing is decided or rendered from it until it is", p.approved(), err)
|
|
}
|
|
}
|
|
if a.Sections == nil {
|
|
a.Sections = map[string]ApprovedSection{}
|
|
}
|
|
if a.Decided == nil {
|
|
a.Decided = map[string]ProposalDecision{}
|
|
}
|
|
if a.Changed == nil {
|
|
a.Changed = map[string]time.Time{}
|
|
}
|
|
if a.Refused == nil {
|
|
a.Refused = map[string]string{}
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func writeApproved(p Paths, a Approved) error {
|
|
raw, err := indented(a)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := p.approved() + ".next"
|
|
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := f.Write(raw); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
// On the disk before it takes the old one's place: a crash leaves the old record or the new, never an empty one.
|
|
if err := f.Sync(); err != nil {
|
|
_ = f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, p.approved()); err != nil {
|
|
return err
|
|
}
|
|
if dir, err := os.Open(p.State); err == nil {
|
|
_ = dir.Sync()
|
|
_ = dir.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApprovedHere is the approved sections that reach this node, by scope then name.
|
|
func ApprovedHere(a Approved, node string) []ApprovedSection {
|
|
var out []ApprovedSection
|
|
for _, s := range a.Sections {
|
|
// Judged again as it renders: a record changed on disk renders nothing a proposal could not hold.
|
|
if !itemName.MatchString(s.Name) || !nodeName.MatchString(s.From) || ProposedTextProblem(s.Text) != "" ||
|
|
textDigest(s.Text) != s.Digest || (s.Scope != ScopeMesh && s.Scope != ScopeNode) ||
|
|
(s.Scope == ScopeNode && (len(s.Nodes) == 0 || nodesProblem(s.Nodes) != "")) {
|
|
continue
|
|
}
|
|
if s.Scope == ScopeMesh || contains(s.Nodes, node) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Scope != out[j].Scope {
|
|
return out[i].Scope < out[j].Scope
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---- the bus, as proposals reach it -----------------------------------------------------------------
|
|
|
|
// 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, and the kinds of the channel bench with what each promises.
|
|
type AskBus interface {
|
|
Publish(subject string, body any, id string) error
|
|
Record(id string) (*stdio.StateEntry, error)
|
|
Kinds() ([]stdio.KindHeld, error)
|
|
}
|
|
|
|
// ProposalStore is the `proposals` state.
|
|
type ProposalStore interface {
|
|
Create(key string, value any) error
|
|
Put(key string, value any) error
|
|
Keys() ([]string, error)
|
|
Get(key string) (json.RawMessage, bool, error)
|
|
}
|
|
|
|
// Recorder records a warrant in the controller's hand-act log: the controller reads the warrant from the
|
|
// router's own record, never from this module.
|
|
type Recorder func(ask string) error
|
|
|
|
type sdkAskBus struct{}
|
|
|
|
func (sdkAskBus) Publish(subject string, body any, id string) error {
|
|
return stdio.SeatPublish(subject, body, id)
|
|
}
|
|
func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) }
|
|
func (sdkAskBus) Kinds() ([]stdio.KindHeld, error) { return stdio.SeatKinds() }
|
|
|
|
// recordWarrant asks the controller to record a warrant this module acted on.
|
|
func recordWarrant(ask string) error {
|
|
_, err := stdio.Ask("seat:mesh-controller.warranted", map[string]any{"asker": Module, "ask": ask})
|
|
return err
|
|
}
|
|
|
|
// ---- proposing --------------------------------------------------------------------------------------
|
|
|
|
// ProposeInput is what an agent proposes.
|
|
type ProposeInput struct {
|
|
Scope string
|
|
Nodes []string
|
|
Name string
|
|
Text string
|
|
Remove bool
|
|
}
|
|
|
|
// AtTheTerminalInstead is the other way, said whenever a proposal is refused for want of a channel.
|
|
const AtTheTerminalInstead = "the operator may set it at the controller's terminal instead: mesh-cli settings set claude-code " +
|
|
"'{\"instructions\": {\"<name>\": [\"line\", …]}}' [--node <node>] (novox/hq issue 340)"
|
|
|
|
// approverReady says why no channel that proves who answered can carry an approval, when this module can tell:
|
|
// the channel bench's kinds, where its membership names them, and none promising a choice, an exact rendering and
|
|
// a verified sender. **The router is the judge** (novox/hq ADR 0259 §4): it refuses an ask none of its ready
|
|
// channels can carry, on the decided subject, and Propose waits for that word. A module that only asks is issued
|
|
// no kinds (they are issued for a kinded bench a module uses or watches, and this module uses the operator
|
|
// channel alone), so an empty list says nothing — read as "none" it refused every proposal on the live mesh on
|
|
// 2026-10-10, with Telegram linked and proven.
|
|
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 == "channel" {
|
|
channels++
|
|
}
|
|
}
|
|
if channels == 0 {
|
|
return ""
|
|
}
|
|
for _, k := range kinds {
|
|
if k.Seat == "channel" && k.Promises("choice") && k.Promises("exact-render") && k.Promises("verified-sender") {
|
|
return ""
|
|
}
|
|
}
|
|
return "no channel that proves who answers (one promising a verified sender, such as Telegram) is on the mesh"
|
|
}
|
|
|
|
func newProposalID() string {
|
|
var b [8]byte
|
|
_, _ = rand.Read(b[:])
|
|
return "instr-" + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// Propose records a proposal and asks the operator. Nothing becomes active here or anywhere: every node applies it
|
|
// only on the operator's warrant, which it proves itself (Reconcile).
|
|
func Propose(p Paths, in ProposeInput, store ProposalStore, bus AskBus, now time.Time) (map[string]any, error) {
|
|
refuse := func(why string) (map[string]any, error) {
|
|
return nil, errors.New(why + ". Nothing was proposed")
|
|
}
|
|
if in.Scope != ScopeMesh && in.Scope != ScopeNode {
|
|
return refuse(fmt.Sprintf("%q is not a scope a shared section is proposed at: mesh (every node) or node (the nodes given); "+
|
|
"a home is registered with claude_code_instructions_register", in.Scope))
|
|
}
|
|
if !itemName.MatchString(in.Name) {
|
|
return refuse(fmt.Sprintf("%q is not a name: lower-case letters, digits and -, at most 64", in.Name))
|
|
}
|
|
var nodes []string
|
|
if in.Scope == ScopeNode {
|
|
for _, n := range in.Nodes {
|
|
if n == "all" {
|
|
return refuse("\"all\" is the mesh scope: propose it with scope mesh")
|
|
}
|
|
if !contains(nodes, n) {
|
|
nodes = append(nodes, n)
|
|
}
|
|
}
|
|
if len(nodes) == 0 {
|
|
nodes = []string{p.Node}
|
|
}
|
|
if problem := nodesProblem(nodes); problem != "" {
|
|
return refuse(problem)
|
|
}
|
|
sort.Strings(nodes)
|
|
} else if len(in.Nodes) > 0 {
|
|
return refuse("the mesh scope reaches every node; name nodes with scope node")
|
|
}
|
|
approved, err := ReadApproved(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
active, isActive := approved.Sections[sectionKey(in.Scope, nodes, in.Name)]
|
|
prop := Proposal{ID: newProposalID(), Scope: in.Scope, Nodes: nodes, Name: in.Name, From: p.Node, At: now.UTC()}
|
|
switch {
|
|
case in.Remove:
|
|
if !isActive {
|
|
return refuse(fmt.Sprintf("no approved section %q at the %s scope is held on this node to remove; one set at the "+
|
|
"controller's terminal is removed there", in.Name, in.Scope))
|
|
}
|
|
prop.Action, prop.Digest = ActionRemove, active.Digest
|
|
default:
|
|
if problem := ProposedTextProblem(in.Text); problem != "" {
|
|
return refuse(problem)
|
|
}
|
|
prop.Action, prop.Text, prop.Digest = ActionAdd, in.Text, textDigest(in.Text)
|
|
if isActive {
|
|
if active.Digest == prop.Digest {
|
|
return refuse(fmt.Sprintf("the section %q already holds exactly this text", in.Name))
|
|
}
|
|
prop.Action = ActionReplace
|
|
}
|
|
}
|
|
if terminal, _ := terminalText(p); len(terminal) > 0 {
|
|
for _, it := range terminal {
|
|
if it.Kind == KindInstructions && it.Name == in.Name {
|
|
return refuse(fmt.Sprintf("a section %q is set at the controller's terminal; it is changed there", in.Name))
|
|
}
|
|
}
|
|
}
|
|
if why := approverReady(bus); why != "" {
|
|
return refuse(why + "; " + AtTheTerminalInstead)
|
|
}
|
|
if waiting := waitingProposals(store, approved, now); waiting >= asks.MostOpen {
|
|
return refuse(fmt.Sprintf("%d proposals already wait for the operator's answer, and the operator is asked at most %d "+
|
|
"questions at once; claude_code_proposals lists them", waiting, asks.MostOpen))
|
|
}
|
|
if problem := ProposalProblem(prop); problem != "" {
|
|
return refuse(problem)
|
|
}
|
|
ask := prop.Ask()
|
|
if err := ask.Check(now); err != nil {
|
|
return refuse(err.Error())
|
|
}
|
|
if err := store.Create(prop.ID, prop); err != nil {
|
|
return nil, fmt.Errorf("the proposal could not be kept, so the operator was not asked: %w", err)
|
|
}
|
|
if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil {
|
|
prop.Asked = "not asked: " + err.Error()
|
|
_ = store.Put(prop.ID, prop)
|
|
say("proposal %s (%s %s %s) could not be asked: %v", prop.ID, prop.Action, prop.Scope, prop.Name, err)
|
|
return nil, fmt.Errorf("the operator could not be asked (%v); the proposal is kept and will never become active. "+
|
|
"Propose it again, or %s", err, AtTheTerminalInstead)
|
|
}
|
|
say("proposal %s (%s %s %s, fingerprint %s) asked of the operator", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint())
|
|
// The router's word, before answering: it refuses at once an ask no channel can carry, and records one it took.
|
|
taken := "the router has not said yet whether it took the ask; claude_code_proposals shows it"
|
|
defer func() { endedMu.Lock(); delete(ended, prop.ID); endedMu.Unlock() }()
|
|
for deadline := time.Now().Add(RouterAnswersWithin); time.Now().Before(deadline); {
|
|
if w, ended := endedAsk(prop.ID); ended {
|
|
return nil, fmt.Errorf("the router did not ask the operator: the ask %s (%s). Nothing is active anywhere; %s",
|
|
w.Outcome, w.Words, AtTheTerminalInstead)
|
|
}
|
|
if entry, err := bus.Record(prop.ID); err == nil && entry != nil {
|
|
taken = "the router took the ask and shows it on the channels that can carry it"
|
|
break
|
|
}
|
|
time.Sleep(RouterAnswersEvery)
|
|
}
|
|
return map[string]any{
|
|
"router": taken,
|
|
"proposed": prop.ID, "action": prop.Action, "reaches": prop.where(), "fingerprint": prop.Fingerprint(),
|
|
"asked": "the operator is asked on a channel that proves who answers: \"" + ask.Headline + "\"",
|
|
"expires": ask.Expires.Format(time.RFC3339),
|
|
"until then": "nothing changes on any node; the text becomes part of those sessions only on the operator's Approve, " +
|
|
"and is discarded on Decline or at expiry. claude_code_proposals shows where it stands",
|
|
}, nil
|
|
}
|
|
|
|
// waitingProposals counts the proposals still open: asked, not decided here, not expired.
|
|
func waitingProposals(store ProposalStore, approved Approved, now time.Time) int {
|
|
keys, err := store.Keys()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n := 0
|
|
for _, k := range keys {
|
|
prop, ok := readProposal(store, k)
|
|
if !ok || prop.Asked != "" {
|
|
continue
|
|
}
|
|
if _, decided := approved.Decided[prop.ID]; decided || now.After(prop.At.Add(ProposalLasts)) {
|
|
continue
|
|
}
|
|
n++
|
|
}
|
|
return n
|
|
}
|
|
|
|
func readProposal(store ProposalStore, key string) (Proposal, bool) {
|
|
raw, found, err := store.Get(key)
|
|
if err != nil || !found {
|
|
return Proposal{}, false
|
|
}
|
|
var prop Proposal
|
|
if json.Unmarshal(raw, &prop) != nil || prop.ID != key {
|
|
return Proposal{}, false
|
|
}
|
|
return prop, true
|
|
}
|
|
|
|
// How long Propose waits for the router's word on a new ask, and how often it looks.
|
|
var (
|
|
RouterAnswersWithin = 8 * time.Second
|
|
RouterAnswersEvery = 250 * time.Millisecond
|
|
)
|
|
|
|
// ended is the asks the router ended without a choice, as heard on the decided subject — which only the router may
|
|
// publish, for this module alone. A refused ask leaves no record of the router's, so this is where its refusal is
|
|
// read.
|
|
var (
|
|
endedMu sync.Mutex
|
|
ended = map[string]asks.Warrant{}
|
|
)
|
|
|
|
func endedAsk(id string) (asks.Warrant, bool) {
|
|
endedMu.Lock()
|
|
defer endedMu.Unlock()
|
|
w, ok := ended[id]
|
|
return w, ok
|
|
}
|
|
|
|
// HeardEnd takes the router's word that one of this module's asks ended without a choice: refused, expired,
|
|
// cancelled or replaced. The proposal is discarded here at once. Nothing is applied on such a word, so it needs no
|
|
// proof beyond its subject; a choice is only ever taken from the router's record (Reconcile).
|
|
func HeardEnd(p Paths, store ProposalStore, w asks.Warrant, now time.Time) (string, error) {
|
|
if w.Asker != Module || w.Outcome == asks.OutcomeChosen || !asks.UsableID(w.Ask) {
|
|
return "", nil
|
|
}
|
|
reconcileMu.Lock()
|
|
defer reconcileMu.Unlock()
|
|
prop, known := readProposal(store, w.Ask)
|
|
if !known || (w.About != "" && w.About != prop.Ask().About) {
|
|
return "", nil
|
|
}
|
|
endedMu.Lock()
|
|
ended[w.Ask] = w
|
|
endedMu.Unlock()
|
|
approved, err := ReadApproved(p)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, done := approved.Decided[w.Ask]; done {
|
|
return "", nil
|
|
}
|
|
// Heard, not read from a record: Reconcile still reads the router's record of it, so an ask the router took after
|
|
// all (its queue offered it again once a channel was ready) is answered as any other.
|
|
d := ProposalDecision{At: now.UTC(), Says: w.Says(), Outcome: "discarded: the ask " + string(w.Outcome), Heard: true}
|
|
if w.Words != "" {
|
|
d.Outcome += " (" + w.Words + ")"
|
|
}
|
|
approved.Decided[w.Ask] = d
|
|
delete(approved.Refused, w.Ask)
|
|
if err := writeApproved(p, approved); err != nil {
|
|
return "", err
|
|
}
|
|
return "proposal " + w.Ask + ": " + d.Outcome, nil
|
|
}
|
|
|
|
// ---- deciding, on every node ------------------------------------------------------------------------
|
|
|
|
// 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"`
|
|
}
|
|
|
|
var reconcileMu sync.Mutex
|
|
|
|
// Reconcile decides, on this node, every proposal it has not decided yet whose ask the router has closed, each held
|
|
// to the warrant as described at the top of this file, oldest warrant first, and never one older than the last
|
|
// change applied to the same section. It answers whether what this node renders changed, and a line for each
|
|
// decision.
|
|
func Reconcile(p Paths, store ProposalStore, bus AskBus, record Recorder, now time.Time) (bool, []string, error) {
|
|
reconcileMu.Lock()
|
|
defer reconcileMu.Unlock()
|
|
approved, err := ReadApproved(p)
|
|
if err != nil {
|
|
return false, nil, err // nothing is decided over a record that cannot be read
|
|
}
|
|
keys, err := store.Keys()
|
|
if err != nil {
|
|
return false, nil, err
|
|
}
|
|
type closed struct {
|
|
prop Proposal
|
|
w asks.Warrant
|
|
}
|
|
var ready []closed
|
|
discarded := false
|
|
for _, k := range keys {
|
|
prop, ok := readProposal(store, k)
|
|
if !ok {
|
|
continue
|
|
}
|
|
d, done := approved.Decided[prop.ID]
|
|
if done && !d.Heard {
|
|
continue
|
|
}
|
|
entry, err := bus.Record(prop.ID)
|
|
if done {
|
|
// Discarded on the router's word alone: only a record of a person's choice reopens it.
|
|
var rec routerRecord
|
|
if err == nil && entry != nil && json.Unmarshal(entry.Value, &rec) == nil && rec.Warrant != nil &&
|
|
rec.Warrant.Outcome == asks.OutcomeChosen {
|
|
ready = append(ready, closed{prop, *rec.Warrant})
|
|
}
|
|
continue
|
|
}
|
|
if err == nil && entry == nil && now.After(prop.At.Add(ProposalLasts+time.Hour)) {
|
|
// Ended, and the router keeps no record of it: refused before it was taken, or kept past its time. Either
|
|
// way no answer can be proven, so it is discarded and nothing is applied.
|
|
approved.Decided[prop.ID] = ProposalDecision{At: now.UTC(),
|
|
Outcome: "discarded: the router keeps no record of its ask (refused before it was taken, or forgotten)"}
|
|
delete(approved.Refused, prop.ID)
|
|
discarded = true
|
|
continue
|
|
}
|
|
if err != nil || entry == nil {
|
|
continue // open, not yet recorded, or not readable now: asked again on the next look
|
|
}
|
|
var rec routerRecord
|
|
if json.Unmarshal(entry.Value, &rec) != nil || rec.State == "open" || rec.Warrant == nil {
|
|
continue
|
|
}
|
|
ready = append(ready, closed{prop, *rec.Warrant})
|
|
}
|
|
sort.SliceStable(ready, func(i, j int) bool { return ready[i].w.At.Before(ready[j].w.At) })
|
|
changed, wrote := false, discarded
|
|
var said []string
|
|
for _, c := range ready {
|
|
d, applied, line, final := decide(p, &approved, c.prop, c.w, now)
|
|
if !final {
|
|
// Not held here now: said once per reason, and judged again on the next look.
|
|
if approved.Refused[c.prop.ID] != d.Outcome {
|
|
approved.Refused[c.prop.ID] = d.Outcome
|
|
said = append(said, line)
|
|
wrote = true
|
|
}
|
|
continue
|
|
}
|
|
delete(approved.Refused, c.prop.ID)
|
|
approved.Decided[c.prop.ID] = d
|
|
changed = changed || applied
|
|
said = append(said, line)
|
|
wrote = true
|
|
}
|
|
// A refusal is kept while the router keeps its record: after that it can never hold.
|
|
for id := range approved.Refused {
|
|
if prop, ok := readProposal(store, id); !ok || now.Sub(prop.At) > ProposalLasts+RouterKeeps {
|
|
delete(approved.Refused, id)
|
|
wrote = true
|
|
}
|
|
}
|
|
// The hand-act log: one entry per warrant mesh-wide (the controller keeps one per ask, read from the router's
|
|
// own record), asked by every node that decided it, and asked again every ten minutes for a week while the
|
|
// controller has not taken it.
|
|
if record != nil {
|
|
for id, d := range approved.Decided {
|
|
if d.Recorded || !d.Warranted || now.Sub(d.At) > RecordFor ||
|
|
(!d.LastTry.IsZero() && now.Sub(d.LastTry) < RecordEvery) {
|
|
continue
|
|
}
|
|
d.LastTry = now.UTC()
|
|
if err := record(id); err != nil {
|
|
d.Record = "not recorded in the hand-act log yet: " + err.Error()
|
|
} else {
|
|
d.Recorded, d.Record = true, ""
|
|
}
|
|
approved.Decided[id] = d
|
|
wrote = true
|
|
}
|
|
}
|
|
if !wrote {
|
|
return false, nil, nil
|
|
}
|
|
if err := writeApproved(p, approved); err != nil {
|
|
return false, said, fmt.Errorf("what this node decided could not be kept: %w", err)
|
|
}
|
|
return changed, said, nil
|
|
}
|
|
|
|
// decide holds one closed ask's warrant to its proposal, and applies it to this node's record when it holds. final
|
|
// is false when the answer did not hold here: it is judged again on the next look, and nothing is applied.
|
|
func decide(p Paths, approved *Approved, prop Proposal, w asks.Warrant, now time.Time) (ProposalDecision, bool, string, bool) {
|
|
d := ProposalDecision{At: now.UTC(), Says: w.Says()}
|
|
refused := func(why string) (ProposalDecision, bool, string, bool) {
|
|
d.Outcome = "refused: " + why
|
|
say("REFUSED the answer to proposal %s: %s", prop.ID, why)
|
|
return d, false, "proposal " + prop.ID + ": refused, " + why, false
|
|
}
|
|
if w.Asker != Module || w.Ask != prop.ID {
|
|
return refused(fmt.Sprintf("the router's record is for %s's ask %s", w.Asker, w.Ask))
|
|
}
|
|
if w.Outcome != asks.OutcomeChosen {
|
|
d.Outcome = "discarded: the ask " + string(w.Outcome)
|
|
if w.Words != "" {
|
|
d.Outcome += " (" + w.Words + ")"
|
|
}
|
|
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
|
|
}
|
|
// The record is judged as this module would have made it: anything holding a tool runner's credential can
|
|
// write one, and ask for it under this module's name.
|
|
if problem := ProposalProblem(prop); problem != "" {
|
|
return refused("the proposal is not one this module makes: " + problem)
|
|
}
|
|
option, err := w.For(Module, prop.Ask())
|
|
if err != nil {
|
|
return refused(err.Error())
|
|
}
|
|
if err := option.Performs(prop.Act(option.ID)); err != nil {
|
|
return refused(err.Error())
|
|
}
|
|
d.Warranted = true
|
|
if option.ID == OptionDecline {
|
|
d.Outcome = "declined"
|
|
return d, false, "proposal " + prop.ID + ": declined by the operator; nothing changes", true
|
|
}
|
|
if option.ID != OptionApprove {
|
|
return refused("the option " + option.ID + " is not one a proposal offers")
|
|
}
|
|
key := sectionKey(prop.Scope, prop.Nodes, prop.Name)
|
|
if last, ok := approved.Changed[key]; ok && !w.At.After(last) {
|
|
d.Outcome = "approved, and superseded here: a change of this section approved later was applied first"
|
|
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
|
|
}
|
|
d.Outcome = "approved"
|
|
reaches := prop.Scope == ScopeMesh || contains(prop.Nodes, p.Node)
|
|
switch prop.Action {
|
|
case ActionRemove:
|
|
held, ok := approved.Sections[key]
|
|
if !ok || held.Digest != prop.Digest {
|
|
d.Outcome = "approved, nothing to remove here: this node holds no section " + prop.Name + " with that fingerprint"
|
|
approved.Changed[key] = w.At.UTC()
|
|
return d, false, "proposal " + prop.ID + ": " + d.Outcome, true
|
|
}
|
|
delete(approved.Sections, key)
|
|
default:
|
|
approved.Sections[key] = ApprovedSection{Name: prop.Name, Scope: prop.Scope, Nodes: prop.Nodes, Text: prop.Text,
|
|
Digest: prop.Digest, Proposal: prop.ID, From: prop.From, Approved: w.At.UTC(), By: w.Says()}
|
|
}
|
|
approved.Changed[key] = w.At.UTC()
|
|
say("proposal %s (%s %s %s, fingerprint %s): %s", prop.ID, prop.Action, prop.Scope, prop.Name, prop.Fingerprint(), w.Says())
|
|
return d, reaches, "proposal " + prop.ID + ": approved, " + prop.Action + " " + prop.Name, true
|
|
}
|
|
|
|
// ---- the list ---------------------------------------------------------------------------------------
|
|
|
|
// ListProposals is every proposal on the bus, newest first, with where the router has it and what this node made
|
|
// of it; with an id, that one with its whole text. And the sections approved here.
|
|
func ListProposals(p Paths, store ProposalStore, bus AskBus, id string, now time.Time) (map[string]any, error) {
|
|
approved, err := ReadApproved(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys, err := store.Keys()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var props []Proposal
|
|
for _, k := range keys {
|
|
if prop, ok := readProposal(store, k); ok && (id == "" || prop.ID == id) {
|
|
props = append(props, prop)
|
|
}
|
|
}
|
|
if id != "" && len(props) == 0 {
|
|
return nil, fmt.Errorf("no proposal %q is kept on the bus", id)
|
|
}
|
|
sort.Slice(props, func(i, j int) bool { return props[i].At.After(props[j].At) })
|
|
var rows []map[string]any
|
|
for _, prop := range props {
|
|
row := map[string]any{"id": prop.ID, "action": prop.Action, "name": prop.Name, "reaches": prop.where(),
|
|
"from": prop.From, "at": prop.At.Format(time.RFC3339), "fingerprint": prop.Fingerprint(), "lines": prop.Lines(),
|
|
"bytes": len(prop.Text)}
|
|
if d, ok := approved.Decided[prop.ID]; ok {
|
|
row["here"] = d
|
|
} else if why, ok := approved.Refused[prop.ID]; ok {
|
|
row["here"] = "the answer did not hold here, judged again on every look: " + why
|
|
} else if prop.Asked != "" {
|
|
row["here"] = prop.Asked + "; it never becomes active"
|
|
} else if now.After(prop.At.Add(ProposalLasts + RouterKeeps)) {
|
|
row["here"] = "cannot be proven here: the router keeps its record of an answer 30 days, and this node never " +
|
|
"read it, so nothing is rendered here; propose it again, or set it at the controller's terminal"
|
|
} else if now.After(prop.At.Add(ProposalLasts)) {
|
|
row["here"] = "not decided here yet: the ask has ended, and the router's word on it has not been read here"
|
|
} else {
|
|
row["here"] = "waiting for the operator's answer; nothing is active"
|
|
}
|
|
if id != "" {
|
|
row["text"] = prop.Text
|
|
row["digest"] = prop.Digest
|
|
if problem := ProposalProblem(prop); problem != "" {
|
|
row["warning"] = "no node will apply this proposal: " + problem
|
|
}
|
|
// Whether the operator's phone shows this text: the ask composed here from the record against the ask
|
|
// the router took, both read now.
|
|
if entry, err := bus.Record(prop.ID); err == nil && entry != nil {
|
|
var rec routerRecord
|
|
if json.Unmarshal(entry.Value, &rec) == nil {
|
|
row["router"] = rec.State
|
|
switch {
|
|
case rec.Ask == nil:
|
|
row["the phone"] = "cannot be compared: the router's record keeps no ask"
|
|
case rec.Ask.Digest() == prop.Ask().Digest():
|
|
row["the phone"] = "shows the ask composed from exactly this text (fingerprint " + prop.Fingerprint() + ")"
|
|
default:
|
|
row["the phone"] = "shows ANOTHER ask than this record composes: do not approve it"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
var here []map[string]any
|
|
for _, s := range ApprovedHere(approved, p.Node) {
|
|
here = append(here, map[string]any{"name": s.Name, "scope": s.Scope, "nodes": s.Nodes, "proposal": s.Proposal,
|
|
"from": s.From, "approved": s.Approved.Format(time.RFC3339), "by": s.By,
|
|
"fingerprint": fingerprintOf(s.Digest)})
|
|
}
|
|
return map[string]any{"proposals": rows, "approved here": here,
|
|
"how": "a proposal becomes active on a node only when that node has held the operator's warrant, read from the " +
|
|
"router's own record, to the proposal's exact text (novox/hq ADR 0274)"}, nil
|
|
}
|